diff --git a/AGENTS.md b/AGENTS.md index ff1cbed..3620f20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,8 @@ # Agent Instructions — First Draft CLI -For release details, follow `RELEASING.md`. +Start with `docs/README.md` and follow its task routes. Detailed command semantics belong in `docs/commands.md`, +handled-error recovery in `docs/errors.md`, living release policy in `RELEASING.md`, and dated release observations +in `docs/release-history.md`. When behavior changes, update its owning document in the same change. - After merging to `main`, report the exact merged SHA and ask whether to coordinate and promote the three-repository candidate. If the user defers, call the SHA unpromoted. Never publish npm, deploy First Draft, or release the diff --git a/README.md b/README.md index 421b127..672b5ce 100644 --- a/README.md +++ b/README.md @@ -1,304 +1,81 @@ # First Draft CLI -`firstdraft` is the command-line client for [First Draft](https://github.com/firstdraft/firstdraft). It is being -built for agents that author and review Foundation Plans with their users. +`firstdraft` is the command-line client for First Draft. It helps agents author and review +Foundation Plans with their users, then submit a valid Plan for the current bounded compilation and private GitHub +publication journey. -The current `0.1.x` line contains the auditable command shell, local Foundation Plan initialization, local -application-key and UUID generation, conditional whole-document push, whole-graph analysis status polling, -compile-and-publish orchestration, and read-only retained-Compilation download. Before `1.0.0`, increasing the minor -version starts a breaking compatibility line; increasing the patch version is otherwise backward-compatible within -that line. This policy applies to ordinary versions; historical prereleases are outside those compatibility -guarantees. `0.1.0` intentionally supersedes `0.1.0-alpha.2` and requires the service's `0.2.x` API contract. -Publishing the CLI does not make the wider First Draft service generally available. +The current `0.1.x` line includes local Plan initialization, application-key and UUID generation, conditional +whole-document push, whole-graph analysis polling, compile-and-publish orchestration, and read-only retained +Compilation download. Remote commands require a compatible First Draft service and are intended for coordinated +trials; publishing this CLI does not make the wider service generally available. -## Requirements +## Install -- Running the CLI: Node.js 22.0.0 or newer -- Working on this repository: Node.js 24.18.0 (pinned in `.tool-versions`) - -## Installation - -Install the `latest`-selected stable release from npm's default channel: +Running the CLI requires Node.js 22.0.0 or newer. Install the stable release selected by npm's `latest` dist-tag: ```sh npm install --global @firstdraft.com/cli firstdraft --version ``` -The npm package is `@firstdraft.com/cli`; it installs the `firstdraft` executable. A versionless installation resolves -npm's `latest` dist-tag. Pin an exact compatible version, such as `@firstdraft.com/cli@0.1.0`, when a repeatable -installation matters. - -The release workflow first publishes an approved candidate under `next`; that channel has no SemVer meaning and the -workflow does not move `latest`. A stable release is complete only after the exact candidate passes its explicitly -named release-specific qualification, receives separate promotion approval, and becomes `latest`. As observed on -August 12, 2026, both `latest` and `next` identify ordinary version `0.1.0`. The earlier alpha remains immutable -registry history but is no longer selected by either channel. Remote Plan and Compilation commands require a -compatible First Draft service and are currently intended for coordinated trials. - -Before creating any future release tag, an operator must inspect npm's exact listed -[GitHub Actions trusted-publisher](https://docs.npmjs.com/trusted-publishers/) relationship for -`@firstdraft.com/cli`, type `github`, the `firstdraft/cli` repository, `publish.yml`, the protected `npm` environment, -and permission `createPackage` (npm's trust-list vocabulary for the allowed publish operation). The release workflow -authenticates only with a short-lived GitHub OIDC credential. A persistent npm token, `NODE_AUTH_TOKEN`, or GitHub -Actions secret is not permitted as a publication fallback. Protected tag `v0.1.0` and package version `0.1.0` are -already consumed and immutable; never move or reuse either identity. - -## Authenticate API commands - -Create an API token in First Draft and provide it only through the environment when running a network command: - -```sh -export FIRSTDRAFT_API_TOKEN="your-token" -firstdraft plan push -``` - -`plan push`, `plan status`, `plan compile`, and `compilation` subcommands send the token as a Bearer credential on -every API request. The CLI does not save it in `.firstdraft`, print it, or require it for local commands such as -`plan init` and `generate`. Revoke the token in First Draft if it is exposed. A missing token, or First Draft's validated -`401` problem response with the `authentication_required` code, produces that stable CLI error. - -## Development - -```sh -npm ci -npm run check -npm run pack:check -``` - -To reproduce the length-delimited SHA-256 used by external evidence to identify the packaged JavaScript runtime -inputs (`package.json`, `bin/firstdraft.js`, and every `.js` file under `src/`), run from the repository root: - -```sh -node scripts/runtime-digest.js -``` +The package installs the `firstdraft` executable. Pin an exact compatible version, such as +`@firstdraft.com/cli@0.1.0`, when a repeatable installation matters. Candidate publication under `next` is not stable +release completion; see the [release policy](RELEASING.md) and [dated release history](docs/release-history.md). -## Start a Foundation Plan +## Shortest current journey -From the project that the Plan describes: +From the project that the Foundation Plan describes: ```sh firstdraft plan init --name "Oscar Party" ``` -This creates an empty `sketch/0.19` Plan and client-generated Project ID under `.firstdraft/`. A nested ignore file -keeps that local scratch area out of Git without changing the project's own `.gitignore`. Initialization makes no -network request and refuses to replace an existing `.firstdraft` path. - -Provide either `--name`, `--application-key`, or both. Name-only initialization derives a lower-snake key. Key-only -initialization derives a humanized display name. Supplying both preserves both values exactly after -validating them against the Foundation Plan schema. To inspect the name-to-key derivation without initializing a -project, run: - -```sh -firstdraft generate application-key --name "Oscar Party" -``` - -The generated key is deterministic, starts with a letter, contains only lowercase ASCII letters, digits, and -underscores, and is at most 63 bytes so it can lower to the current iOS application identifier component. Names -without a readable ASCII form receive a stable digest-based key. Longer readable names are shortened to a readable -prefix plus a stable digest suffix. Explicit application keys retain the Foundation Plan's broader -`^[a-z][a-z0-9_]*$` boundary and are left for target analysis rather than silently rewritten. - -## Add Foundation Plan subjects - -Generate an identity before adding each new independently mutable authored subject: +Edit `.firstdraft/foundation-plan.json`, preserving each authored subject's UUID across renames and moves that do not +replace the concept. Generate new subject identities locally as needed: ```sh firstdraft generate uuid ``` -The command prints one UUIDv7 for the subject's `subject_uuid`. It does not read or modify the Plan, reserve the -value, or make a network request. Preserve that UUID when renaming the subject or moving it to a different semantic -owner without changing its kind. Use a new UUID for a replacement concept. Readable keys and paths may change and -remain the document's links; the UUID preserves continuity between complete-document pushes. - -Use `--count ` to print several independently generated UUIDv7 values, one per line. - -## Push a Foundation Plan - -From the initialized project: - -```sh -firstdraft plan push -``` - -The command sends the exact bytes in `.firstdraft/foundation-plan.json`. The first push conditionally creates the -Project; later pushes replay the complete ETag saved in `.firstdraft/state.json` so a stale writer cannot replace a -newer Plan. Successful responses and server diagnostics are printed as JSON for an agent to inspect. - -The initial API origin defaults to `https://firstdraft.com`. Set `FIRSTDRAFT_API_URL` to use another HTTPS origin -or a loopback HTTP development server. The first successful push pins the normalized origin in local state, and a -later override must match it. - -If a failure happens after sending the request, the CLI reports that the outcome may be ambiguous and leaves local -state unchanged. It never constructs an ETag from the Plan digest or trusts an ETag from a response it could not -fully verify. Until First Draft has a Foundation Plan head reconciliation endpoint, an accepted request whose -response cannot be verified may require manual recovery. If a verified response cannot replace local state, -preserve the printed recovery state; an adjacent `.tmp` file may contain the same private recovery copy. - -## Read analysis status - -After a successful push: - -```sh -firstdraft plan status -firstdraft plan status --wait -``` - -Without `--wait`, the command makes one `GET` and prints the current analysis as one JSON object. With `--wait`, it -polls sequentially once per second for at most two minutes and stops at `valid`, `issues_found`, `analysis_failed`, -or `superseded`. Every validated analysis status is a successful read with exit 0; agents should branch on the -`analysis.status` value and inspect `analysis.diagnostics` rather than treating a completed analysis with issues as -a transport failure. - -Status reads require the API origin pinned by a successful push. They never select an origin from the current -environment, expose the private ETag, follow redirects, or modify local state. Each request has a bounded timeout, -every response is byte-bounded and fully validated, and polling will not silently switch to a replacement analysis. -The wait repeats only validated `processing` responses and stops on its first failed read. A network failure is safe -to retry a bounded number of times because the command sends only `GET` requests. If `status_unavailable` persists, -inspect the API origin pinned in `.firstdraft/state.json`; an invalid server response instead requires reconciling the -CLI and server contract. - -## Compile and publish the current Plan - -When the candidate is ready, run: +Provide an API token only through the environment, then submit, analyze, compile, and publish the exact current Plan: ```sh +export FIRSTDRAFT_API_TOKEN="your-token" firstdraft plan compile ``` -`plan compile` is the single terminal action. It first pushes the exact current bytes in -`.firstdraft/foundation-plan.json`, even when those bytes are unchanged, and saves the accepted ETag using the same -contract as `plan push`. It then waits up to two minutes for an analysis whose graph version exactly matches that -accepted push, polling past a terminal result retained for an older Head. Invalid JSON, schema diagnostics, -semantic diagnostics, a failed analysis, a superseded analysis, or a recurring diagnostic stop the command with -structured output; no Compilation or Publication is requested. - -Only a `valid` analysis proceeds to the internal GitHub Publication lifecycle. Invoking `plan compile` is the -authorization to request that lifecycle. Immediately before its conditional mutation, the CLI re-reads the local -Plan and requires its exact bytes to match the accepted Head, so bytes changed after analysis cannot be published. -It extracts the accepted source SHA-256 from the saved ETag, hashes the current local bytes, and then sends that -complete ETag in `If-Match`. -The command writes stable human-readable progress to stderr, with every line prefixed by `First Draft:`. It reports -analysis, compilation completion or terminal failure or cancellation, the current GitHub phase, and an allowlisted -reason, retry count, and exact UTC retry time when a GitHub preflight check is delayed. A retained retry with no next -time is reported as paused and requiring operator recovery. Progress never includes IDs, hashes, repository names or -URLs, raw server projections, local paths, or environment values. Success writes exactly the validated private -GitHub repository URL plus a newline to stdout. If the command fails after progress has begun, its existing -structured JSON error envelope is the final stderr document after the progress lines. - -The closed API `0.2.x` progress-reason allowlist is `github.configuration_missing`, `github.oauth_unavailable`, -`github.api_unavailable`, `github.reauthorization_required`, `github.account_mismatch`, -`github.installation_unavailable`, `github.installation_not_ready`, `github.preflight_unavailable`, the legacy-only -`github.preflight_unclassified`, and these stage-specific fallbacks: `github.preflight_unavailable.configuration`, -`github.preflight_unavailable.authorization`, `github.preflight_unavailable.repository_client`, -`github.preflight_unavailable.artifact_preparation`, `github.preflight_unavailable.installation_token`, -`github.preflight_unavailable.publication_preparation`, and `github.preflight_unavailable.repository_ref_client`. -Other values make the response invalid rather than becoming terminal output. +Invoking `plan compile` authorizes the internal GitHub Publication lifecycle. It proceeds only after the accepted +Plan's analysis is valid, writes allowlisted progress to standard error, and on success writes only the validated +private GitHub repository URL to standard output. The current Publication is a Project singleton and cannot be +repointed to a later accepted Head. Read the [complete command contract](docs/commands.md#compile-and-publish-the-current-plan) +before using it and follow [phase-specific recovery](docs/errors.md#ambiguous-mutations) after an ambiguous mutation. -The internal Publication is a Project singleton in this release. A repeat safely receives the same Publication -instead of creating another. If the first conditional `PUT` has an ambiguous result, the CLI reconciles it with -one read-only singleton `GET` and never automatically repeats the mutation within that invocation. Do not run -concurrent Compile commands. After an invocation exits because the initial outcome or a later status read is -unavailable, wait and rerun `plan compile` with unchanged Plan bytes; its conditional request safely reconciles or -resumes the same retained singleton without creating another Compilation, repository, or push. Publication polling -is sequential, bounded to ten minutes, and pinned to the retained Project Head, Compilation input, Publication -identity, and repository identity. - -This release cannot repoint a Project's Publication to a later accepted Head. The public CLI therefore has no -`plan publish` command and no local-start `plan compile --output` mode. It retains lower-level Compilation commands -for operational callers that acquire an ID separately, but they are intentionally not a continuation of the -URL-only `plan compile` journey. - -## Inspect a retained Compilation - -These lower-level commands are for callers that already hold a retained Compilation ID from authenticated API -metadata or operational tooling; `plan compile` prints only the final repository URL: - -```sh -firstdraft compilation status 01900000-0000-7000-8000-000000000001 -firstdraft compilation status 01900000-0000-7000-8000-000000000001 --wait -``` - -Without `--wait`, the command makes exactly one metadata-only `GET`. With `--wait`, it polls that same -Compilation sequentially for at most ten minutes and rejects changes to its identity, Head provenance, target, or -lifecycle progression. `failed` and `cancelled` are successfully read terminal states with exit 0; branch on -`compilation.status` and inspect its validated `failure`. - -## Download a retained Compilation - -Materialize an already successful Compilation into an absent path: - -```sh -firstdraft compilation download 01900000-0000-7000-8000-000000000001 --output ../movie-catalog -``` - -The command validates the UUID and output path before network access, makes one status `GET`, requires -`succeeded`, and makes one artifact `GET`. It never starts work or polls. Historical artifact validation uses -the retained `compilation.head_source_sha256`, not the current local Plan or ETag, to pin the artifact's exact -`head_source_sha256`. The artifact's canonical `foundation_plan.sha256` may differ because it identifies the -normalized Compiler input. It is validated as a SHA-256 digest inside the exact artifact bytes authenticated by -the status response's `artifact.sha256`; it is not equated to the submitted Head digest. - -Before materialization, the CLI verifies the artifact media type, declared and actual byte sizes, strong digest -ETag, exact-byte SHA-256, canonical UTF-8 JSON envelope, provenance, metadata-only manifest digest, portable paths, -strict Base64 contents, file digests, modes, owners, and source-subject UUIDs. It writes only into a uniquely -created sibling directory, verifies the complete tree, and atomically renames it into the still-absent destination. -On POSIX, directories use mode `0755` and files use artifact-declared `0644` or `0755`; Windows verifies -structure, contents, and digests without claiming POSIX mode bits. - -## Handled failures - -Every handled subcommand failure ends with exactly one JSON object on standard error. `plan compile` may first write -progress lines; machine consumers can remove only lines beginning with the exact `First Draft: ` prefix and parse -the remaining JSON document. Branch on its stable `error` value rather than the human-readable `detail`; `plan -compile` also supplies `phase: "push" | "publication"` when `request_outcome_unknown` requires phase-specific -recovery: - -- `phase: "push"` means the Plan mutation may have been accepted; stop and reconcile local Head state. -- `phase: "publication"` means the singleton Publication mutation was not resolved. Do not run concurrent Compile - commands. After the prior invocation exits, wait and rerun `plan compile` with unchanged Plan bytes to safely - reconcile or resume the retained singleton. - -| Commands | `error` | Exit | Meaning | -| -------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---: | -------------------------------------------------------------------------------------------------------------- | -| Any leaf command | `invalid_arguments` | 2 | Syntax was invalid; no request was made. | -| `plan init` | `local_initialization_failed` | 1 | Initialization failed without overwriting an existing path. | -| `plan push`, `plan compile` | `invalid_configuration` | 2 | API origin or saved Head state is incompatible. | -| Network commands | `authentication_required` | 1 | The token is missing or First Draft returned a validated authentication problem. | -| Plan commands, `compilation *` | `local_input_unreadable` | 1 | Required local Plan or private state could not be read. | -| Status, Compile, Compilation commands | `project_not_pushed` | 1 | No API origin is pinned for the local Project. | -| `plan push`, `plan compile` | `request_outcome_unknown` | 1 | A mutation or its response could not be verified; `plan compile` identifies its `push` or `publication` phase. | -| `plan push`, `plan compile` | `local_state_not_saved` | 1 | The Plan was accepted but the private ETag state could not be replaced; includes `recovery_state`. | -| `plan push`, `plan compile` | `server_rejected` | 1 | First Draft returned validated Plan diagnostics or a validated problem. | -| `plan status` | `status_unavailable`, `invalid_server_response` | 1 | The analysis read failed or violated its protocol. | -| `plan compile` | `analysis_status_unavailable`, `invalid_analysis_status`, `analysis_status_rejected` | 1 | The bounded analysis read failed, was invalid, or was rejected. | -| Analysis waits | `analysis_changed`, `wait_timed_out`, `analysis_wait_timed_out` | 1 | The pinned analysis changed or remained processing at the deadline. | -| `plan compile` | `plan_not_valid` | 1 | Analysis completed without `valid`; `current` contains diagnostics and status. | -| `plan compile` | `local_plan_changed` | 1 | Local bytes changed after acceptance or analysis, before Publication mutation. | -| `plan compile` | `publication_start_rejected`, `publication_status_unavailable`, `invalid_publication_status` | 1 | Publication start or status failed its validated transport contract. | -| `plan compile` | `publication_changed`, `publication_wait_timed_out`, `publication_failed`, `publication_cancelled` | 1 | The pinned Publication changed, timed out, or reached a non-success terminal state. | -| `compilation status`, `compilation download` | `compilation_status_unavailable`, `invalid_compilation_status` | 1 | The retained status could not be read or violated its exact contract. | -| `compilation status --wait` | `compilation_changed`, `compilation_wait_timed_out` | 1 | Retained identity/provenance changed or the wait ended. | -| `compilation download` | `compilation_not_succeeded` | 1 | Status was not `succeeded`; no artifact request was made. | -| `compilation download` | `artifact_unavailable`, `invalid_artifact` | 1 | Artifact transport or integrity validation failed before materialization. | -| `compilation download` | `invalid_output_path` | 2 | The destination was not an absent path beneath an existing real directory. | -| `compilation download` | `materialization_failed` | 1 | The output raced or the verified tree could not be atomically installed. | - -Handled output never includes command arguments, local Plan bytes, raw artifact bytes, raw filesystem or network -errors, or unvalidated response bodies. `local_state_not_saved` is the sole exception to private-state redaction: -its `recovery_state` is required to repair the accepted ETag locally. Root-level and command-group usage failures -remain human-readable text on standard error with exit 2. Unexpected programming defects remain loud. +To review analysis before that terminal action, use `firstdraft plan push` followed by +`firstdraft plan status --wait`. See [Command reference](docs/commands.md) for all supported commands, flags, output +contracts, and retained-Compilation operations. ## Trust model -- The published CLI will run the reviewed JavaScript source directly, without generated or bundled code. -- The CLI has no runtime dependencies, install scripts, telemetry, update checks, or network activity except an - explicitly invoked API command. -- Package contents are allowlisted and checked before release. +- The published CLI runs the reviewed JavaScript source directly, without generated or bundled code. +- It has no runtime dependencies, install scripts, telemetry, update checks, or network activity except an explicitly + invoked API command. +- API tokens are read from `FIRSTDRAFT_API_TOKEN`, sent as Bearer credentials, and never saved in `.firstdraft` or + printed. Revoke an exposed token in First Draft. +- Package contents are allowlisted and checked before release; repository-only documentation is not packaged. - CI exercises the exact minimum Node.js version separately from current development tooling. -- Public packages carry npm provenance linking their registry bytes to the reviewed GitHub workflow and commit. - -Security issues should follow the -[private reporting instructions](https://github.com/firstdraft/cli/security/advisories/new). +- Public packages carry npm provenance linking registry bytes to the reviewed GitHub workflow and commit. + +## Find the right documentation + +| Task | Read | +| ------------------------------------------------------- | ------------------------------------------ | +| Install and complete the shortest current journey | This README | +| Choose a command or inspect its exact behavior | [Command reference](docs/commands.md) | +| Interpret an error or recover safely | [Errors and recovery](docs/errors.md) | +| Contribute to this repository | [Documentation map](docs/README.md) | +| Prepare, publish, verify, recover, or promote a release | [Release policy and runbook](RELEASING.md) | +| Check dated package, tag, or channel observations | [Release history](docs/release-history.md) | +| Report a vulnerability | [Security policy](SECURITY.md) | + +Run `firstdraft --help` or a command group's `--help` for concise terminal syntax. The documentation map explains +which source owns each longer-lived contract. diff --git a/RELEASING.md b/RELEASING.md index 19f4837..a5637ec 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,5 +1,8 @@ # Releasing First Draft CLI +This document owns living release policy and the operator runbook. Dated tag, package, qualification, and dist-tag +observations belong in [release history](docs/release-history.md) and must be rechecked before an operation. + Publishing is a separate, explicit action after a release-preparation pull request has merged. npm registry bytes and package versions cannot be replaced, so do not create or push a release tag as a dry run. @@ -12,9 +15,8 @@ reviewed digests identify it. Reconcile the registry and remote tags before deci Before `1.0.0`, First Draft CLI uses ordinary `0.MINOR.PATCH` versions. Increase `MINOR` for a breaking compatibility-line change. Increase `PATCH` for a change that is otherwise backward-compatible within the current minor line. Never reuse a published version to preserve compatibility; publish the next version required by this -policy. Do not add aliases or shims solely to make a breaking compatibility line look patch-compatible. -The policy applies to ordinary versions. Historical prereleases do not establish an ordinary compatibility line; -`0.1.0` intentionally supersedes `0.1.0-alpha.2` and requires the service's `0.2.x` API contract. +policy. Do not add aliases or shims solely to make a breaking compatibility line look patch-compatible. The policy +applies to ordinary versions; historical prereleases do not establish an ordinary compatibility line. Version semantics and npm distribution channels are independent. An approved candidate is published first under the approval-gated `next` tag even when it has an ordinary version such as `0.1.0`. The release workflow does not @@ -54,15 +56,15 @@ because the compatibility check passes. ## Repository and registry controls -The published scoped package already exists. Before another release, a repository administrator must confirm: +Before a release, a repository administrator must confirm: 1. Confirm `firstdraft/cli` is public. The release workflow deliberately removes checkout credentials and re-fetches the public release refs anonymously. -2. Protect `main` with pull-request and CI requirements, and add a `v*` tag ruleset that restricts tag creation, - update, and deletion. -3. Create a GitHub environment named `npm`, restrict it to release tags, require an explicit reviewer, disable - administrator bypass, and add the environment variable `NPM_RELEASE_ENABLED=true`. The workflow fails before - publishing when this variable is absent. +2. Confirm `main` has pull-request and CI requirements and a `v*` tag ruleset restricts tag creation, update, and + deletion. +3. Confirm the GitHub environment named `npm` is restricted to release tags, requires an explicit reviewer, disables + administrator bypass, and defines `NPM_RELEASE_ENABLED=true`. The workflow fails before publishing when this + variable is absent. 4. Confirm that the `firstdraft.com` npm organization and `@firstdraft.com/cli` package still identify the intended publisher and repository. The publisher account must have write-protecting 2FA enabled. Verify authenticated identity, organization membership, package identity, and current tags: @@ -73,10 +75,9 @@ The published scoped package already exists. Before another release, a repositor npm view '@firstdraft.com/cli' name repository.url versions dist-tags --json ``` -5. Before creating any future `v` tag, verify npm trusted publishing for the exact package, - repository, workflow, protected environment, and allowed publish operation. Protected `v0.1.0` and package - version `0.1.0` are already consumed and immutable. No persistent npm credential is permitted for a release - workflow. +5. Before creating a `v` tag, verify [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) + for the exact package, repository, workflow, protected environment, and allowed publish operation. No persistent + npm credential is permitted for a release workflow. Use the repository-pinned Node.js 24.18.0 toolchain with npm 11.16.0 to verify the organization's durable read/write access: @@ -89,8 +90,7 @@ npm access list packages firstdraft.com:developers '@firstdraft.com/cli' --json Require the package to report `read-write` access for the `developers` team. If access is missing or changed, stop: restoring it is a separate administrator and security mutation, not a routine release step. -The first ordinary release established the trusted-publisher relationship. For every future release, inspect it -read-only: +For every release, inspect the existing trusted-publisher relationship read-only: ```sh npm trust list '@firstdraft.com/cli' @@ -107,23 +107,7 @@ publication credential; an authentication failure stops the release and must nev As optional defense-in-depth after trusted publication is operationally proven, an npm administrator may complete the separate security-key ceremony and set package **Publishing access** to **Require two-factor authentication and -disallow tokens**. This package-level setting was not a `v0.1.0` release prerequisite and must not be reported as -enabled until it is directly observed. - -## Historical alpha publications - -The immutable `v0.1.0-alpha.1` tag records the first reviewed release candidate. On July 31, 2026, npm rejected its -unscoped `firstdraft` name as too similar to the existing `first-draft` package before creating a registry package. -Do not move or reuse that tag or version. The immutable `v0.1.0-alpha.2` tag identifies the first -organization-scoped package, `@firstdraft.com/cli@0.1.0-alpha.2`, published on August 5, 2026. As observed on August -7, 2026, that is the only published scoped version and both npm's `next` and `latest` tags identify it. Those are -historical release and registry facts; they do not require prerelease syntax, aliases, or compatibility shims for -the ordinary `0.1.0` candidate. On August 7, 2026, protected tag `v0.1.0` published ordinary version `0.1.0` under -`next` while `latest` continued to identify `0.1.0-alpha.2`. On August 12, after the selected bounded CLI 0.1.0 -user-journey smoke passed and under separate promotion approval, `latest` was promoted to `0.1.0`; both `next` and -`latest` then identified `0.1.0`. Full v14 service qualification remained separate and incomplete. The alpha remains -immutable version history, but neither distribution channel selects it. Preparing this source does not mutate either -dist-tag. +disallow tokens**. Do not report that setting as enabled until it is directly observed. ## Prepare a release @@ -151,12 +135,11 @@ dist-tag. ## Publish -The manual boundary is creation of the version tag. From an up-to-date, clean `main`, verify the intended commit, -confirm that both the intended package version and `v` tag are absent, then create and push that -tag. Protected `v0.1.0` and package version `0.1.0` are already consumed; prepare the next version required by the -pre-1.0 policy rather than moving or reusing either identity. -Push one release tag at a time; the workflow serializes publication, but GitHub retains at most one pending run in a -concurrency group. +The manual boundary is creation of the version tag. From an up-to-date, clean `main`, verify the intended commit and +confirm that both the intended package version and `v` tag are absent. If either identity is already +consumed, prepare the next version required by the pre-1.0 policy rather than moving or reusing it. Otherwise create +and push the tag. Push one release tag at a time; the workflow serializes publication, but GitHub retains at most one +pending run in a concurrency group. The workflow rejects accidental or stale inputs unless they use a protected `v*` tag in `firstdraft/cli`, the tag equals `v` plus the version in `package.json`, the remote tag still identifies the triggering commit, and that commit @@ -217,8 +200,8 @@ npm dist-tag add "@firstdraft.com/cli@$FD_CLI_RELEASE_VERSION" latest npm dist-tag ls '@firstdraft.com/cli' ``` -Verify both tags after the mutation, then land a documentation change that replaces README's dated `latest` -observation with the promoted version. The stable CLI release is complete only after `next` and `latest` both name -the exact release-specific qualified version. Do not call a candidate fully promoted before that equality is +Verify both tags after the mutation, then append the exact dated observation and qualification boundary to +[release history](docs/release-history.md). The stable CLI release is complete only after `next` and `latest` both +name the exact release-specific qualified version. Do not call a candidate fully promoted before that equality is observed. Do not move `latest` merely because a release merged, published successfully, or passed candidate compatibility checks, and do not use a dist-tag change to repair or disguise a bad immutable version. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..eae81c2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,48 @@ +# First Draft CLI documentation + +Use this page to find the narrowest authoritative document for a task. Runtime source and tests remain the final +evidence for implemented behavior; if they contradict a document, surface the contradiction instead of guessing. + +| If the task concerns... | Read first | +| -------------------------------------------------------------------------------------- | --------------------------------------------- | +| Public installation, trust, or the shortest current journey | [Root README](../README.md) | +| Commands, options, environment variables, API behavior, output, or materialization | [Command reference](commands.md) | +| Stable errors, exit codes, retry safety, ambiguous outcomes, or local recovery | [Errors and recovery](errors.md) | +| Version policy, release preparation, publication, verification, rollback, or promotion | [Release policy and runbook](../RELEASING.md) | +| What was observed for an earlier tag, package, or dist-tag | [Release history](release-history.md) | +| Vulnerability reporting | [Security policy](../SECURITY.md) | + +## Authority boundaries + +- [README.md](../README.md) owns public onboarding, the shortest supported journey, current trust claims, and routes. +- [commands.md](commands.md) owns detailed command semantics. Built-in `--help`, runtime source, and tests own exact + executable syntax and behavior. +- [errors.md](errors.md) owns handled-error interpretation and recovery guidance. +- [RELEASING.md](../RELEASING.md) owns living release policy and the operator runbook. +- [release-history.md](release-history.md) preserves dated release observations. Recheck live tags, package versions, + dist-tags, access, and trusted-publisher state before relying on them operationally. +- [AGENTS.md](../AGENTS.md) routes agent work; it should stay compact rather than duplicate these documents. + +## Work on the repository + +Development uses Node.js 24.18.0 and npm 11.16.0, pinned in `.tool-versions`. From a fresh checkout: + +```sh +npm ci --ignore-scripts +npm audit +npm run check +``` + +`npm run check` runs type checking, linting, formatting, tests, the exact package allowlist check, and a packed-package +smoke test. To inspect the package manifest without writing a tarball: + +```sh +npm pack --dry-run --json --ignore-scripts +``` + +To reproduce the length-delimited SHA-256 used by external evidence to identify packaged JavaScript runtime inputs +(`package.json`, `bin/firstdraft.js`, and every `.js` file under `src/`), run: + +```sh +node scripts/runtime-digest.js +``` diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..76407c0 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,211 @@ +# First Draft CLI command reference + +This page owns the detailed public semantics of the current command surface. Run `firstdraft --help` or a command +group's `--help` for concise executable syntax. See [Errors and recovery](errors.md) before retrying a failed mutation. + +The current `0.1.x` line contains the auditable command shell, local Foundation Plan initialization, local +application-key and UUID generation, conditional whole-document push, whole-graph analysis status polling, +compile-and-publish orchestration, and read-only retained-Compilation download. The current `0.1.0` CLI requires the +service's `0.2.x` API contract. See the [release policy](../RELEASING.md) for versioning and channel semantics and +[release history](release-history.md) for the transition from prereleases. + +## Command map + +| Command | Network | Purpose | +| ------------------------------------- | ------- | ---------------------------------------------------------- | +| `firstdraft plan init` | No | Create an empty local Foundation Plan and Project identity | +| `firstdraft generate application-key` | No | Preview deterministic name-to-key derivation | +| `firstdraft generate uuid` | No | Generate one or more Foundation Plan subject identities | +| `firstdraft plan push` | Yes | Conditionally submit the exact whole Plan | +| `firstdraft plan status` | Yes | Read or wait for the current whole-graph analysis | +| `firstdraft plan compile` | Yes | Push, analyze, compile, and publish the current Plan | +| `firstdraft compilation status` | Yes | Inspect a retained Compilation by ID | +| `firstdraft compilation download` | Yes | Verify and materialize a successful retained Compilation | + +## Authenticate API commands + +Create an API token in First Draft and provide it only through the environment when running a network command: + +```sh +export FIRSTDRAFT_API_TOKEN="your-token" +firstdraft plan push +``` + +`plan push`, `plan status`, `plan compile`, and `compilation` subcommands send the token as a Bearer credential on +every API request. The CLI does not save it in `.firstdraft`, print it, or require it for local commands such as +`plan init` and `generate`. Revoke the token in First Draft if it is exposed. A missing token, or First Draft's +validated `401` problem response with the `authentication_required` code, produces that stable CLI error. + +## Start a Foundation Plan + +From the project that the Plan describes: + +```sh +firstdraft plan init --name "Oscar Party" +``` + +This creates an empty `sketch/0.19` Plan and client-generated Project ID under `.firstdraft/`. A nested ignore file +keeps that local scratch area out of Git without changing the project's own `.gitignore`. Initialization makes no +network request and refuses to replace an existing `.firstdraft` path. + +Provide either `--name`, `--application-key`, or both. Name-only initialization derives a lower-snake key. Key-only +initialization derives a humanized display name. Supplying both preserves both values exactly after validating them +against the Foundation Plan schema. + +### Generate an application key + +To inspect the name-to-key derivation without initializing a project, run: + +```sh +firstdraft generate application-key --name "Oscar Party" +``` + +The generated key is deterministic, starts with a letter, contains only lowercase ASCII letters, digits, and +underscores, and is at most 63 bytes so it can lower to the current iOS application identifier component. Names +without a readable ASCII form receive a stable digest-based key. Longer readable names are shortened to a readable +prefix plus a stable digest suffix. Explicit application keys retain the Foundation Plan's broader +`^[a-z][a-z0-9_]*$` boundary and are left for target analysis rather than silently rewritten. + +### Generate Foundation Plan subject identities + +Generate an identity before adding each new independently mutable authored subject: + +```sh +firstdraft generate uuid +``` + +The command prints one UUIDv7 for the subject's `subject_uuid`. It does not read or modify the Plan, reserve the +value, or make a network request. Preserve that UUID when renaming the subject or moving it to a different semantic +owner without changing its kind. Use a new UUID for a replacement concept. Readable keys and paths may change and +remain the document's links; the UUID preserves continuity between complete-document pushes. + +Use `--count ` to print several independently generated UUIDv7 values, one per line. + +## Push a Foundation Plan + +From the initialized project: + +```sh +firstdraft plan push +``` + +The command sends the exact bytes in `.firstdraft/foundation-plan.json`. The first push conditionally creates the +Project; later pushes replay the complete ETag saved in `.firstdraft/state.json` so a stale writer cannot replace a +newer Plan. Successful responses and server diagnostics are printed as JSON for an agent to inspect. + +The initial API origin defaults to `https://firstdraft.com`. Set `FIRSTDRAFT_API_URL` to use another HTTPS origin or +a loopback HTTP development server. The first successful push pins the normalized origin in local state, and a later +override must match it. + +If a failure happens after sending the request, the CLI leaves local state unchanged. It never constructs an ETag +from the Plan digest or trusts an ETag from a response it could not fully verify. Follow +[ambiguous-mutation recovery](errors.md#ambiguous-mutations) rather than blindly retrying. + +## Read analysis status + +After a successful push: + +```sh +firstdraft plan status +firstdraft plan status --wait +``` + +Without `--wait`, the command makes one `GET` and prints the current analysis as one JSON object. With `--wait`, it +polls sequentially once per second for at most two minutes and stops at `valid`, `issues_found`, `analysis_failed`, +or `superseded`. Every validated analysis status is a successful read with exit 0; agents should branch on the +`analysis.status` value and inspect `analysis.diagnostics` rather than treating a completed analysis with issues as +a transport failure. + +Status reads require the API origin pinned by a successful push. They never select an origin from the current +environment, expose the private ETag, follow redirects, or modify local state. Each request has a bounded timeout, +every response is byte-bounded and fully validated, and polling will not silently switch to a replacement analysis. +The wait repeats only validated `processing` responses and stops on its first failed read. A network failure is safe +to retry a bounded number of times because the command sends only `GET` requests. See +[read-only failures](errors.md#read-only-status-failures) if the problem persists. + +## Compile and publish the current Plan + +When the candidate is ready, run: + +```sh +firstdraft plan compile +``` + +`plan compile` is the single terminal action. It first pushes the exact current bytes in +`.firstdraft/foundation-plan.json`, even when those bytes are unchanged, and saves the accepted ETag using the same +contract as `plan push`. It then waits up to two minutes for an analysis whose graph version exactly matches that +accepted push, polling past a terminal result retained for an older Head. Invalid JSON, schema diagnostics, semantic +diagnostics, a failed analysis, a superseded analysis, or a recurring diagnostic stop the command with structured +output; no Compilation or Publication is requested. + +Only a `valid` analysis proceeds to the internal GitHub Publication lifecycle. Invoking `plan compile` is the +authorization to request that lifecycle. Immediately before its conditional mutation, the CLI re-reads the local +Plan and requires its exact bytes to match the accepted Head, so bytes changed after analysis cannot be published. +It extracts the accepted source SHA-256 from the saved ETag, hashes the current local bytes, and sends that complete +ETag in `If-Match`. + +The command writes stable human-readable progress to stderr, with every line prefixed by `First Draft:`. It reports +analysis, compilation completion or terminal failure or cancellation, the current GitHub phase, and an allowlisted +reason, retry count, and exact UTC retry time when a GitHub preflight check is delayed. A retained retry with no next +time is reported as paused and requiring operator recovery. Progress never includes IDs, hashes, repository names or +URLs, raw server projections, local paths, or environment values. Success writes exactly the validated private +GitHub repository URL plus a newline to stdout. If the command fails after progress has begun, its structured JSON +error envelope is the final stderr document after the progress lines. + +The closed API `0.2.x` progress-reason allowlist is `github.configuration_missing`, `github.oauth_unavailable`, +`github.api_unavailable`, `github.reauthorization_required`, `github.account_mismatch`, +`github.installation_unavailable`, `github.installation_not_ready`, `github.preflight_unavailable`, the legacy-only +`github.preflight_unclassified`, and these stage-specific fallbacks: `github.preflight_unavailable.configuration`, +`github.preflight_unavailable.authorization`, `github.preflight_unavailable.repository_client`, +`github.preflight_unavailable.artifact_preparation`, `github.preflight_unavailable.installation_token`, +`github.preflight_unavailable.publication_preparation`, and `github.preflight_unavailable.repository_ref_client`. +Other values make the response invalid rather than becoming terminal output. + +The internal Publication is a Project singleton in this release. A repeat safely receives the same Publication +instead of creating another. If the first conditional `PUT` has an ambiguous result, the CLI reconciles it with one +read-only singleton `GET` and never automatically repeats the mutation within that invocation. Publication polling +is sequential, bounded to ten minutes, and pinned to the retained Project Head, Compilation input, Publication +identity, and repository identity. Do not run concurrent Compile commands; use the +[publication recovery procedure](errors.md#publication-recovery) after an invocation exits. + +This release cannot repoint a Project's Publication to a later accepted Head. The public CLI therefore has no +`plan publish` command and no local-start `plan compile --output` mode. It retains lower-level Compilation commands +for operational callers that acquire an ID separately, but they are intentionally not a continuation of the +URL-only `plan compile` journey. + +## Inspect a retained Compilation + +These lower-level commands are for callers that already hold a retained Compilation ID from authenticated API +metadata or operational tooling; `plan compile` prints only the final repository URL: + +```sh +firstdraft compilation status 01900000-0000-7000-8000-000000000001 +firstdraft compilation status 01900000-0000-7000-8000-000000000001 --wait +``` + +Without `--wait`, the command makes exactly one metadata-only `GET`. With `--wait`, it polls that same Compilation +sequentially for at most ten minutes and rejects changes to its identity, Head provenance, target, or lifecycle +progression. `failed` and `cancelled` are successfully read terminal states with exit 0; branch on +`compilation.status` and inspect its validated `failure`. + +## Download a retained Compilation + +Materialize an already successful Compilation into an absent path: + +```sh +firstdraft compilation download 01900000-0000-7000-8000-000000000001 --output ../movie-catalog +``` + +The command validates the UUID and output path before network access, makes one status `GET`, requires `succeeded`, +and makes one artifact `GET`. It never starts work or polls. Historical artifact validation uses the retained +`compilation.head_source_sha256`, not the current local Plan or ETag, to pin the artifact's exact +`head_source_sha256`. The artifact's canonical `foundation_plan.sha256` may differ because it identifies the +normalized Compiler input. It is validated as a SHA-256 digest inside the exact artifact bytes authenticated by the +status response's `artifact.sha256`; it is not equated to the submitted Head digest. + +Before materialization, the CLI verifies the artifact media type, declared and actual byte sizes, strong digest +ETag, exact-byte SHA-256, canonical UTF-8 JSON envelope, provenance, metadata-only manifest digest, portable paths, +strict Base64 contents, file digests, modes, owners, and source-subject UUIDs. It writes only into a uniquely created +sibling directory, verifies the complete tree, and atomically renames it into the still-absent destination. On +POSIX, directories use mode `0755` and files use artifact-declared `0644` or `0755`; Windows verifies structure, +contents, and digests without claiming POSIX mode bits. diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..8c707df --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,86 @@ +# First Draft CLI errors and recovery + +This page owns handled-error interpretation, retry safety, and recovery guidance. Read the +[command reference](commands.md) for successful command behavior. + +## Handled output contract + +Every handled subcommand failure ends with exactly one JSON object on standard error. `plan compile` may first write +progress lines; machine consumers can remove only lines beginning with the exact `First Draft: ` prefix and parse the +remaining JSON document. Branch on the stable `error` value rather than the human-readable `detail`. + +Handled output never includes command arguments, local Plan bytes, raw artifact bytes, raw filesystem or network +errors, or unvalidated response bodies. `local_state_not_saved` is the sole exception to private-state redaction: +its `recovery_state` is required to repair the accepted ETag locally. Root-level and command-group usage failures +remain human-readable text on standard error with exit 2. Unexpected programming defects remain loud. + +## Ambiguous mutations + +`plan compile` supplies `phase: "push" | "publication"` when `request_outcome_unknown` requires phase-specific +recovery: + +- `phase: "push"` means the Plan mutation may have been accepted. Stop and reconcile local Head state. Until First + Draft has a Foundation Plan Head reconciliation endpoint, an accepted request whose response cannot be verified + may require manual recovery. Do not construct an ETag from the Plan digest or trust an unverified response. +- `phase: "publication"` means the singleton Publication mutation was not resolved. Do not run concurrent Compile + commands. After the prior invocation exits, wait and rerun `plan compile` with unchanged Plan bytes to safely + reconcile or resume the retained singleton. + +`plan push` also reports `request_outcome_unknown` if a failure happens after sending its request. Local state remains +unchanged. Stop and reconcile rather than assuming the request failed and repeating the mutation. + +If a verified Plan response cannot replace local state, preserve the printed `recovery_state`; an adjacent `.tmp` +file may contain the same private recovery copy. Do not push again until the accepted ETag state is repaired. + +## Read-only status failures + +A network failure from `plan status` is safe to retry a bounded number of times because the command sends only +`GET` requests. If `status_unavailable` persists, inspect the API origin pinned in `.firstdraft/state.json`. +`invalid_server_response` instead means the response violated the CLI/server contract; retrying the unchanged read +will not repair it. + +The lower-level `compilation status` command is also read-only. `compilation_status_unavailable` is safe to retry; +`invalid_compilation_status` requires contract reconciliation. A wait stops rather than following a changed +analysis or Compilation identity. + +## Publication recovery + +The Publication is a Project singleton. If its initial conditional `PUT` is ambiguous, the CLI attempts one +read-only singleton `GET` and never automatically repeats the mutation in that invocation. + +After `publication_status_unavailable` or `publication_wait_timed_out`, retained work may still continue. Do not run +concurrent Compile commands. Wait, then rerun `plan compile` with unchanged Plan bytes; the conditional request +safely reconciles or resumes the same retained singleton without creating another Compilation, repository, or push. +The same recovery applies when an invocation exits after an unresolved Publication start. + +A GitHub preflight retry with no `retry_at` is paused and requires operator recovery. `publication_failed` and +`publication_cancelled` are terminal; inspect the validated phase and failure information rather than blindly +retrying. `publication_changed` means the pinned Publication identity or provenance changed, so the CLI deliberately +stopped without following the replacement. + +## Error index + +| Commands | `error` | Exit | Meaning | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---: | -------------------------------------------------------------------------------------------------------------- | +| Any leaf command | `invalid_arguments` | 2 | Syntax was invalid; no request was made. | +| `plan init` | `local_initialization_failed` | 1 | Initialization failed without overwriting an existing path. | +| `plan push`, `plan compile` | `invalid_configuration` | 2 | API origin or saved Head state is incompatible. | +| Network commands | `authentication_required` | 1 | The token is missing or First Draft returned a validated authentication problem. | +| Plan commands, `compilation *` | `local_input_unreadable` | 1 | Required local Plan or private state could not be read. | +| Status, Compile, Compilation commands | `project_not_pushed` | 1 | No API origin is pinned for the local Project. | +| `plan push`, `plan compile` | `request_outcome_unknown` | 1 | A mutation or its response could not be verified; `plan compile` identifies its `push` or `publication` phase. | +| `plan push`, `plan compile` | `local_state_not_saved` | 1 | The Plan was accepted but the private ETag state could not be replaced; includes `recovery_state`. | +| `plan push`, `plan status`, `plan compile` | `server_rejected` | 1 | First Draft returned validated Plan diagnostics or rejected the request with a validated problem. | +| `plan status` | `status_unavailable`, `invalid_server_response` | 1 | The analysis read failed or violated its protocol. | +| `plan compile` | `analysis_status_unavailable`, `invalid_analysis_status`, `analysis_status_rejected` | 1 | The bounded analysis read failed, was invalid, or was rejected. | +| Analysis waits | `analysis_changed`, `wait_timed_out`, `analysis_wait_timed_out` | 1 | The pinned analysis changed or remained processing at the deadline. | +| `plan compile` | `plan_not_valid` | 1 | Analysis completed without `valid`; `current` contains diagnostics and status. | +| `plan compile` | `local_plan_changed` | 1 | Local bytes changed after acceptance or analysis, before Publication mutation. | +| `plan compile` | `publication_start_rejected`, `publication_status_unavailable`, `invalid_publication_status` | 1 | Publication start or status failed its validated transport contract. | +| `plan compile` | `publication_changed`, `publication_wait_timed_out`, `publication_failed`, `publication_cancelled` | 1 | The pinned Publication changed, timed out, or reached a non-success terminal state. | +| `compilation status`, `compilation download` | `compilation_status_unavailable`, `invalid_compilation_status` | 1 | The retained status could not be read or violated its exact contract. | +| `compilation status --wait` | `compilation_changed`, `compilation_wait_timed_out` | 1 | Retained identity/provenance changed or the wait ended. | +| `compilation download` | `compilation_not_succeeded` | 1 | Status was not `succeeded`; no artifact request was made. | +| `compilation download` | `artifact_unavailable`, `invalid_artifact` | 1 | Artifact transport or integrity validation failed before materialization. | +| `compilation download` | `invalid_output_path` | 2 | The destination was not an absent path beneath an existing real directory. | +| `compilation download` | `materialization_failed` | 1 | The output raced or the verified tree could not be atomically installed. | diff --git a/docs/release-history.md b/docs/release-history.md new file mode 100644 index 0000000..1c9c9b8 --- /dev/null +++ b/docs/release-history.md @@ -0,0 +1,32 @@ +# First Draft CLI release history + +This page preserves dated release and registry observations. It is historical evidence, not a statement of current +npm, GitHub, service, or qualification state. Before acting, recheck the registry, protected tags, exact source SHA, +compatibility declarations, trusted-publisher relationship, and named release-specific qualification by following +the living [release policy and runbook](../RELEASING.md). + +## 0.1.0 alpha publications + +- On July 31, 2026, npm rejected the unscoped `firstdraft` name for `v0.1.0-alpha.1` as too similar to the existing + `first-draft` package before creating a registry package. The tag records the first reviewed release candidate and + is immutable; neither its tag nor version may be moved or reused. +- On August 5, 2026, `v0.1.0-alpha.2` became the first organization-scoped publication, + `@firstdraft.com/cli@0.1.0-alpha.2`. +- As observed earlier on August 7, 2026, `0.1.0-alpha.2` was the only published scoped version and both npm's `next` + and `latest` dist-tags identified it. + +## 0.1.0 ordinary release and promotion + +- Later on August 7, 2026, protected tag `v0.1.0` published ordinary version `0.1.0` under `next`, while `latest` + continued to identify `0.1.0-alpha.2`. The ordinary release intentionally superseded the alpha and required the + service's `0.2.x` API contract; the historical prerelease did not define an ordinary compatibility line. +- The first ordinary release established the npm trusted-publisher relationship used by the release workflow. +- On August 12, 2026, the selected bounded CLI `0.1.0` user-journey smoke passed and separate promotion approval was + granted. `latest` was promoted to `0.1.0`; both `next` and `latest` then identified ordinary version `0.1.0`. Full + v14 service qualification remained separate and incomplete. +- Requiring two-factor authentication while disallowing tokens at the package publishing-access layer was not a + `v0.1.0` release prerequisite and was not established by that release evidence. + +The alpha versions remain immutable registry history but, as of the August 12 observation, neither distribution +channel selected them. Protected tag `v0.1.0` and package version `0.1.0` were consumed and immutable. Preparing +source or documentation does not mutate either dist-tag. diff --git a/scripts/check-pack.js b/scripts/check-pack.js index ef635d7..b49709d 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -20,6 +20,12 @@ if (result.status !== 0) { assert(manifest, "npm pack did not return a manifest"); const paths = manifest.files.map(({ path }) => path).sort(); + assert.equal( + paths.some((filePath) => filePath.startsWith("docs/")), + false, + "repository documentation must stay outside the npm tarball", + ); + assert.deepEqual(paths, [ "LICENSE", "README.md", diff --git a/test/documentation.test.js b/test/documentation.test.js new file mode 100644 index 0000000..15eeb62 --- /dev/null +++ b/test/documentation.test.js @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repository = fileURLToPath(new URL("..", import.meta.url)); +const markdownFiles = [ + ...["AGENTS.md", "README.md", "RELEASING.md", "SECURITY.md"].map((file) => + path.join(repository, file), + ), + ...findMarkdownFiles(path.join(repository, "docs")), +]; + +const sources = new Map( + markdownFiles.map((file) => [file, readFileSync(file, "utf8")]), +); + +test("documentation routes commands, recovery, and release knowledge", () => { + const readme = sources.get(path.join(repository, "README.md")); + const index = sources.get(path.join(repository, "docs/README.md")); + const instructions = sources.get(path.join(repository, "AGENTS.md")); + const releasing = sources.get(path.join(repository, "RELEASING.md")); + const history = sources.get(path.join(repository, "docs/release-history.md")); + + assert(readme); + assert(index); + assert(instructions); + assert(releasing); + assert(history); + + assert.match( + readme, + /`firstdraft` is the command-line client for First Draft\./, + ); + assert.doesNotMatch( + readme, + /\[First Draft\]\(https:\/\/firstdraft\.com\)/, + "public onboarding must not route readers to the unrelated site at the API origin", + ); + assert.match(readme, /\[Command reference\]\(docs\/commands\.md\)/); + assert.match(readme, /\[Errors and recovery\]\(docs\/errors\.md\)/); + assert.match(readme, /\[Release history\]\(docs\/release-history\.md\)/); + assert.match(index, /\[Command reference\]\(commands\.md\)/); + assert.match(index, /\[Errors and recovery\]\(errors\.md\)/); + assert.match(index, /\[Release policy and runbook\]\(\.\.\/RELEASING\.md\)/); + assert.match(instructions, /Start with `docs\/README\.md`/); + assert.match(releasing, /\[release history\]\(docs\/release-history\.md\)/); + assert.match(history, /historical evidence, not a statement of current/); + + const publicDocumentation = [...sources.values()].join("\n"); + assert.doesNotMatch( + publicDocumentation, + /https:\/\/github\.com\/firstdraft\/firstdraft(?:[\s/)#]|$)/, + ); + assert.doesNotMatch( + `${readme}\n${releasing}`, + /\b(?:July|August) \d{1,2}, 2026\b/, + ); +}); + +test("local documentation links and fragments resolve", () => { + for (const [sourceFile, source] of sources) { + for (const target of markdownLinkTargets(source)) { + if (isExternalTarget(target)) continue; + + const [rawPath, rawFragment] = target.split("#", 2); + const targetFile = rawPath + ? path.resolve(path.dirname(sourceFile), decodeURIComponent(rawPath)) + : sourceFile; + + assert.equal( + existsSync(targetFile) && statSync(targetFile).isFile(), + true, + `${path.relative(repository, sourceFile)} links to missing ${target}`, + ); + + if (rawFragment === undefined || rawFragment === "") continue; + + const fragment = decodeURIComponent(rawFragment).toLowerCase(); + const targetSource = + sources.get(targetFile) ?? readFileSync(targetFile, "utf8"); + assert.equal( + markdownHeadingFragments(targetSource).has(fragment), + true, + `${path.relative(repository, sourceFile)} links to missing fragment ${target}`, + ); + } + } +}); + +/** @param {string} directory @returns {string[]} */ +function findMarkdownFiles(directory) { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) return findMarkdownFiles(entryPath); + return entry.isFile() && entry.name.endsWith(".md") ? [entryPath] : []; + }) + .sort(); +} + +/** @param {string} source @returns {string[]} */ +function markdownLinkTargets(source) { + const targets = []; + const linkPattern = /(?$/g, "")); + } + + return targets; +} + +/** @param {string} target */ +function isExternalTarget(target) { + return /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//"); +} + +/** @param {string} source @returns {Set} */ +function markdownHeadingFragments(source) { + const fragments = new Set(); + const counts = new Map(); + + for (const line of withoutFencedCode(source).split("\n")) { + const match = /^ {0,3}#{1,6}\s+(.+?)\s*#*\s*$/.exec(line); + if (!match) continue; + + const heading = match[1]; + assert(heading); + const base = heading + .toLowerCase() + .replace(/<[^>]*>/g, "") + .replace(/[`*_~]/g, "") + .replace(/[^\p{L}\p{N}\s-]/gu, "") + .trim() + .replace(/\s+/g, "-"); + const count = counts.get(base) ?? 0; + counts.set(base, count + 1); + fragments.add(count === 0 ? base : `${base}-${count}`); + } + + return fragments; +} + +/** @param {string} source */ +function withoutFencedCode(source) { + /** @type {string | undefined} */ + let fence; + + return source + .split("\n") + .filter((line) => { + const match = /^ {0,3}(`{3,}|~{3,})/.exec(line); + if (match) { + const marker = match[1]; + assert(marker); + + if (fence === undefined) { + fence = marker[0]; + } else if (marker[0] === fence) { + fence = undefined; + } + + return false; + } + + return fence === undefined; + }) + .join("\n"); +} diff --git a/test/package.test.js b/test/package.test.js index 2b3f632..4230ebd 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -17,6 +17,10 @@ const releasingGuide = await readFile( new URL("../RELEASING.md", import.meta.url), "utf8", ); +const releaseHistory = await readFile( + new URL("../docs/release-history.md", import.meta.url), + "utf8", +); const agentInstructions = await readFile( new URL("../AGENTS.md", import.meta.url), "utf8", @@ -61,6 +65,7 @@ test("package metadata preserves the audited runtime boundary", () => { assert.equal(metadata.engines.node, ">=22.0.0"); assert.deepEqual(metadata.bin, { firstdraft: "bin/firstdraft.js" }); assert.deepEqual(metadata.files, ["bin", "src"]); + assert.equal(metadata.files.includes("docs"), false); assert.equal(metadata.scripts.test, "node scripts/run-tests.js"); for (const property of [ @@ -101,16 +106,20 @@ test("stable release completion requires qualified latest promotion", () => { /Release-specific qualification means the exact gate named for that candidate; it does not imply unrelated or full[\s\S]*?service qualification/, ); assert.match( - releasingGuide, - /On August 7, 2026,[\s\S]*?`next` while `latest` continued to identify `0\.1\.0-alpha\.2`[\s\S]*?On August 12,[\s\S]*?selected bounded CLI 0\.1\.0[\s\S]*?user-journey smoke passed[\s\S]*?separate promotion approval[\s\S]*?both `next` and\s+`latest` then identified `0\.1\.0`[\s\S]*?Full v14 service qualification remained separate and incomplete/, + releaseHistory, + /Later on August 7, 2026,[\s\S]*?`next`, while `latest`[\s\S]*?continued to identify `0\.1\.0-alpha\.2`[\s\S]*?On August 12, 2026,[\s\S]*?selected bounded CLI `0\.1\.0` user-journey smoke passed[\s\S]*?separate promotion approval[\s\S]*?both `next` and `latest` then identified ordinary version `0\.1\.0`[\s\S]*?Full\s+v14 service qualification remained separate and incomplete/, ); assert.match( readme, - /`latest`-selected stable release[\s\S]*?As observed on\s+August 12, 2026, both `latest` and `next`[\s\S]*?identify ordinary version `0\.1\.0`[\s\S]*?alpha remains immutable[\s\S]*?registry history[\s\S]*?no longer selected by[\s\S]*?either channel/, + /stable release selected by npm's `latest` dist-tag[\s\S]*?Candidate publication under `next` is not stable\s+release completion[\s\S]*?\[dated release history\]\(docs\/release-history\.md\)/, + ); + assert.match( + releaseHistory, + /Protected tag `v0\.1\.0` and package version `0\.1\.0` were consumed and immutable/, ); assert.match( releasingGuide, - /Protected `v0\.1\.0` and package version `0\.1\.0` are already consumed[\s\S]*?prepare the next version required by the[\s\S]*?pre-1\.0 policy rather than moving or reusing either identity/, + /If either identity is already[\s\S]*?consumed, prepare the next version required by the pre-1\.0 policy rather than moving or reusing it/, ); assert.doesNotMatch( `${readme}\n${releasingGuide}`,