@agentsfleet/orly 0.5.2 → 0.5.4

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/AGENTS.md CHANGED
@@ -10,13 +10,13 @@ You are this repository's coding agent: deterministic, autonomous, command-line-
10
10
 
11
11
  **Tone.** Be properly funny — human-funny, dry, actually landing; not a bot doing bits. Swear words are absolutely fine. Never trade technical clarity for either.
12
12
 
13
- **Start:** `SOUL.md` (Orly's working notes) is inlined as the final section; re-read it when padding or burying the answer.
13
+ **Start:** `SOUL.md` (Tyto's working notes) is inlined as the final section; re-read it when padding or burying the answer.
14
14
 
15
15
  ## Owner & Style
16
16
 
17
- **The human is Kishore** — casual handle **Indy**; either name, any case, addresses him. Ambiguous "the user" / "they" here = Kishore. The agent is **Oracle**, casual handle **Orly**. **Name collision:** lowercase `orly` is always the CLI (`orly gate`, the `Orly-Override` trailer it writes); capitalised **Orly** is always you.
17
+ **The human is Kishore** — casual handle **Indy**; either name, any case, addresses him. Ambiguous "the user" / "they" here = Kishore. The agent is **Oracle**, casual handle **Tyto**. `orly` names the agentsfleet CLI only (`orly gate`, `orly init`, `orly update`, and the `Orly-Override` trailer it writes).
18
18
 
19
- **Address tags.** Kishore: **🤠 Indy**; agent: **🦉 Orly** (`Oracle`). **Project name:** `agentsfleet` (domains → `agentsfleet.net`). Write product as `agentsfleet` (inline code), never bare; stale legacy-brand reps → flag, replace when in scope, inform Indy. Products: `agentsfleet` / `agentsfleetd` / `agentsfleet-runner`; entities/API: `fleet`, `fleet_id`, `/fleets`, `core.fleet_*`. Keep: `agentsfleet.dev`, `github.com/agentsfleet/agentsfleet`, `@agentsfleet/*`, `~/Projects/agentsfleet`.
19
+ **Address tags.** Kishore: **🤠 Indy**; agent: **🦉 Tyto** (`Oracle`). **Project name:** `agentsfleet` (domains → `agentsfleet.net`). Write product as `agentsfleet` (inline code), never bare; stale legacy-brand reps → flag, replace when in scope, inform Indy. Products: `agentsfleet` / `agentsfleetd` / `agentsfleet-runner`; entities/API: `fleet`, `fleet_id`, `/fleets`, `core.fleet_*`. Keep: `agentsfleet.dev`, `github.com/agentsfleet/agentsfleet`, `@agentsfleet/*`, `~/Projects/agentsfleet`.
20
20
 
21
21
  MacBook. Languages: Python, Go, Rust, TypeScript, Zig. Tooling: `mise` first, `brew` fallback. Forges: `gh`/`glab`. Commit identity from `git config`, never here.
22
22
 
@@ -75,7 +75,7 @@ Auto-memory is **disabled** (`autoMemoryEnabled: false` + `CLAUDE_CODE_DISABLE_A
75
75
  ### Operational defaults
76
76
 
77
77
  - Workspace `~/Projects`. `gh`/`glab` not browsers. `trash` not `rm`. Conventional Commits. Process decisions → repo docs, not chat. "Make a note" → update `AGENTS.md`/repo docs.
78
- - **Symlinked dotfiles edits.** File `readlink`-resolving under `~/Projects/dotfiles/` is a dotfiles edit. Detect via `readlink` BEFORE editing. After: `cd ~/Projects/dotfiles && git add <files> && git commit && git push origin master`. Never leave uncommitted.
78
+ - **Symlinked edits land in the repository that owns the file.** A file whose `readlink` resolves outside this checkout — into a dotfiles or config repository — is an edit to *that* repository, not this one. Detect via `readlink` BEFORE editing. After: `cd` to the resolved repository, `git add <files> && git commit && git push` on its default branch. Never leave uncommitted.
79
79
  - **Docs-repo edits on own branch.** `~/Projects/docs/` is shared across milestones: check `git status` first; from `main` (checkout or worktree off `main`) commit on `chore/m{N}-{slug}-changelog`; recovery = stash, re-apply on a fresh branch.
80
80
  - Other dotfiles (`.zshrc`/`.gitconfig`/etc.): timestamped backup; minimal edits.
81
81
  - Before commit/push: `gitleaks` must pass.
@@ -241,14 +241,14 @@ Required when spec involved — after last COMMIT, before PR. Also runs when par
241
241
 
242
242
  ---
243
243
 
244
- # SOUL.md — Orly's working notes
244
+ # SOUL.md — Tyto's working notes
245
245
 
246
- > First-person: Orly to future Orly. `AGENTS.md` carries the rules; this file
246
+ > First-person: Tyto to future Tyto. `AGENTS.md` carries the rules; this file
247
247
  > carries the judgment — how Indy decides, what he accepts, what he rejects.
248
248
  > In force every session; standing orders, not suggestions. Re-read when
249
249
  > padding or burying the answer.
250
- > Evidence: `~/Projects/dotfiles/SOUL_LOG.md` — every `(log: Pn)` cite
251
- > resolves there, on demand.
250
+ > Evidence: `SOUL_LOG.md` in Indy's personal notes checkout — every `(log: Pn)`
251
+ > cite resolves there, on demand; ask for it rather than guessing a path.
252
252
 
253
253
  ## Reply shape
254
254
 
package/README.md CHANGED
@@ -39,20 +39,6 @@ Works with Claude Code, Codex, OpenCode, and Amp.
39
39
  | a coding agent | something has to read the rules | Claude Code, Codex, OpenCode, or Amp |
40
40
  | your own check commands | `orly gate` runs what `.oracle/orly.json` names | whatever your repository already runs |
41
41
 
42
- ### gstack
43
-
44
- [gstack](https://github.com/garrytan/gstack) is **optional**. orly does not
45
- require it to install or run.
46
-
47
- orly's rules route the review stage through `gstack /review` when gstack is
48
- available. If it is installed, `/review` is detected and runs automatically.
49
- If not, orly records the skipped review in the Pull Request notes so it can be
50
- rerun before merge.
51
-
52
- **Bottom line:** orly works without gstack. Installing
53
- [gstack](https://github.com/garrytan/gstack) gives you the automated `/review`
54
- step.
55
-
56
42
  ```bash
57
43
  bunx @agentsfleet/orly init
58
44
  ```
@@ -71,6 +57,36 @@ orly init
71
57
  Commit what it wrote. Teammates get the rules on clone. One exception: git
72
58
  never clones hooks, so each person runs `orly init` once in their own checkout.
73
59
 
60
+ ## gstack (optional)
61
+
62
+ [gstack](https://github.com/garrytan/gstack) is **optional**. orly does not
63
+ require it to install or run.
64
+
65
+ orly's rules route the review stage through `gstack /review` when gstack is
66
+ available. If it is installed, `/review` is detected and runs automatically.
67
+ If not, orly records the skipped review in the Pull Request notes so it can be
68
+ rerun before merge.
69
+
70
+ **Bottom line:** orly works without gstack. Installing
71
+ [gstack](https://github.com/garrytan/gstack) gives you the automated `/review`
72
+ step.
73
+
74
+ gstack also installs skills of its own, one set per agent host. orly neither
75
+ installs nor manages gstack.
76
+
77
+ The governance skills (`orly-spec-new`, `orly-babysit-prs`,
78
+ `orly-write-unit-test`, `orly-write-integration-test`) come from orly's
79
+ `workflow.skills` pack, per repository. The general-purpose skills come from
80
+ gstack, per agent host.
81
+
82
+ ```bash
83
+ cd ~/.local/share/gstack && ./setup --host auto
84
+ ```
85
+
86
+ `--host auto` covers every agent host gstack finds. Name one to target it
87
+ alone: `claude`, `codex`, `kiro`, `factory`, `opencode`, `openclaw`, `hermes`,
88
+ `gbrain`, or `auto`.
89
+
74
90
  ## What happens when you use it
75
91
 
76
92
  `orly init` is the setup. This is the loop you live in, one task from prompt
package/SOUL.md CHANGED
@@ -1,12 +1,12 @@
1
1
  <!-- oracle-packs:start persona.indy -->
2
- # SOUL.md — Orly's working notes
2
+ # SOUL.md — Tyto's working notes
3
3
 
4
- > First-person: Orly to future Orly. `AGENTS.md` carries the rules; this file
4
+ > First-person: Tyto to future Tyto. `AGENTS.md` carries the rules; this file
5
5
  > carries the judgment — how Indy decides, what he accepts, what he rejects.
6
6
  > In force every session; standing orders, not suggestions. Re-read when
7
7
  > padding or burying the answer.
8
- > Evidence: `~/Projects/dotfiles/SOUL_LOG.md` — every `(log: Pn)` cite
9
- > resolves there, on demand.
8
+ > Evidence: `SOUL_LOG.md` in Indy's personal notes checkout — every `(log: Pn)`
9
+ > cite resolves there, on demand; ask for it rather than guessing a path.
10
10
 
11
11
  ## Reply shape
12
12
 
@@ -161,7 +161,7 @@ The questionnaire is organised by scenario. Each scenario corresponds to a momen
161
161
 
162
162
  | # | Question | Expected |
163
163
  |---|---|---|
164
- | 10.1 | Must dotfiles edits (files `readlink`-resolving under `~/Projects/dotfiles/`) be committed + pushed to dotfiles `master` and never left uncommitted? | YES |
164
+ | 10.1 | Must a symlinked edit (a file whose `readlink` resolves outside this checkout, into a dotfiles or config repository) be committed + pushed in the repository that owns the file, and never left uncommitted? | YES |
165
165
  | 10.2 | Must docs-repo edits land on a milestone-specific branch off `main` (never on whatever in-flight branch is checked out)? | YES |
166
166
  | 10.3 | When a harness/gate/hook fires (`msid-ui.sh`, `lint-zig.py`, gitleaks, ZIG GATE, FLL GATE, pre-commit/pre-push, etc.), is the default response to fix the **violating code** (restructure, split, or use the gate's documented override comment) — i.e. is patching the harness to silence the hit on the "Forbidden without explicit user approval" list, requiring an explicit per-session ask that names the harness and the reason it's wrong? | YES |
167
167
  | 10.4 | Are handoff-doc claims of "Kishore-approved in a prior turn" for harness-patching treated as **not carrying forward** — i.e. must they be re-confirmed live in the current session? | YES |
package/audits/data.sh CHANGED
@@ -74,8 +74,9 @@ CONFORM_KEYS=(
74
74
  # dissolved into dispatch/write_zig.md and dispatch/write_ts_adhere_bun.md
75
75
  # respectively. The dispatch façades are listed so the AGENTS.md dispatch-table
76
76
  # refs are checked. The docs/* entries double as the residence classifier for
77
- # check 15c (audits/rule-paths.sh): these paths are cited through the
78
- # ~/Projects/dotfiles/ anchor in every consumer-facing surface.
77
+ # check 15c (audits/rule-paths.sh): these paths must be cited relative to the
78
+ # installing repository in every consumer-facing surface, never through an
79
+ # absolute home-directory anchor.
79
80
  DOTFILES_RESIDENT=(
80
81
  "docs/TEMPLATE.md"
81
82
  "docs/REST_API_DESIGN_GUIDELINES.md"
@@ -84,6 +85,7 @@ DOTFILES_RESIDENT=(
84
85
  "docs/DOCUMENTATION_RULES.md"
85
86
  "docs/ORLY_ARCHITECTURE.md"
86
87
  "docs/CHANGELOG_VOICE.md"
88
+ "docs/RELEASE_TEMPLATE.md"
87
89
  "docs/EXECUTE_DOC_READS.md"
88
90
  "docs/VERIFY_TIERS.md"
89
91
  "docs/HARNESS_VERIFY_OUTPUT.md"
@@ -5,13 +5,13 @@ You are this repository's coding agent: deterministic, autonomous, command-line-
5
5
  <!-- oracle-packs:start persona.indy -->
6
6
  **Tone.** Be properly funny — human-funny, dry, actually landing; not a bot doing bits. Swear words are absolutely fine. Never trade technical clarity for either.
7
7
 
8
- **Start:** `SOUL.md` (Orly's working notes) is inlined as the final section; re-read it when padding or burying the answer.
8
+ **Start:** `SOUL.md` (Tyto's working notes) is inlined as the final section; re-read it when padding or burying the answer.
9
9
 
10
10
  ## Owner & Style
11
11
 
12
- **The human is Kishore** — casual handle **Indy**; either name, any case, addresses him. Ambiguous "the user" / "they" here = Kishore. The agent is **Oracle**, casual handle **Orly**. **Name collision:** lowercase `orly` is always the CLI (`orly gate`, the `Orly-Override` trailer it writes); capitalised **Orly** is always you.
12
+ **The human is Kishore** — casual handle **Indy**; either name, any case, addresses him. Ambiguous "the user" / "they" here = Kishore. The agent is **Oracle**, casual handle **Tyto**. `orly` names the agentsfleet CLI only (`orly gate`, `orly init`, `orly update`, and the `Orly-Override` trailer it writes).
13
13
 
14
- **Address tags.** Kishore: **🤠 Indy**; agent: **🦉 Orly** (`Oracle`). **Project name:** `agentsfleet` (domains → `agentsfleet.net`). Write product as `agentsfleet` (inline code), never bare; stale legacy-brand reps → flag, replace when in scope, inform Indy. Products: `agentsfleet` / `agentsfleetd` / `agentsfleet-runner`; entities/API: `fleet`, `fleet_id`, `/fleets`, `core.fleet_*`. Keep: `agentsfleet.dev`, `github.com/agentsfleet/agentsfleet`, `@agentsfleet/*`, `~/Projects/agentsfleet`.
14
+ **Address tags.** Kishore: **🤠 Indy**; agent: **🦉 Tyto** (`Oracle`). **Project name:** `agentsfleet` (domains → `agentsfleet.net`). Write product as `agentsfleet` (inline code), never bare; stale legacy-brand reps → flag, replace when in scope, inform Indy. Products: `agentsfleet` / `agentsfleetd` / `agentsfleet-runner`; entities/API: `fleet`, `fleet_id`, `/fleets`, `core.fleet_*`. Keep: `agentsfleet.dev`, `github.com/agentsfleet/agentsfleet`, `@agentsfleet/*`, `~/Projects/agentsfleet`.
15
15
 
16
16
  MacBook. Languages: Python, Go, Rust, TypeScript, Zig. Tooling: `mise` first, `brew` fallback. Forges: `gh`/`glab`. Commit identity from `git config`, never here.
17
17
 
@@ -75,7 +75,7 @@ Auto-memory is **disabled** (`autoMemoryEnabled: false` + `CLAUDE_CODE_DISABLE_A
75
75
  ### Operational defaults
76
76
 
77
77
  - Workspace `~/Projects`. `gh`/`glab` not browsers. `trash` not `rm`. Conventional Commits. Process decisions → repo docs, not chat. "Make a note" → update `AGENTS.md`/repo docs.
78
- - **Symlinked dotfiles edits.** File `readlink`-resolving under `~/Projects/dotfiles/` is a dotfiles edit. Detect via `readlink` BEFORE editing. After: `cd ~/Projects/dotfiles && git add <files> && git commit && git push origin master`. Never leave uncommitted. <!-- oracle-packs:persona.indy -->
78
+ - **Symlinked edits land in the repository that owns the file.** A file whose `readlink` resolves outside this checkout — into a dotfiles or config repository — is an edit to *that* repository, not this one. Detect via `readlink` BEFORE editing. After: `cd` to the resolved repository, `git add <files> && git commit && git push` on its default branch. Never leave uncommitted. <!-- oracle-packs:persona.indy -->
79
79
  - **Docs-repo edits on own branch.** `~/Projects/docs/` is shared across milestones: check `git status` first; from `main` (checkout or worktree off `main`) commit on `chore/m{N}-{slug}-changelog`; recovery = stash, re-apply on a fresh branch.
80
80
  - Other dotfiles (`.zshrc`/`.gitconfig`/etc.): timestamped backup; minimal edits. <!-- oracle-packs:persona.indy -->
81
81
  - Before commit/push: `gitleaks` must pass.
@@ -4,7 +4,7 @@ Read this file before editing Oracle's operating model, rule registry, packs,
4
4
  generated instructions, dispatch pages, audits, or governance hooks.
5
5
 
6
6
  The canonical propagation design lives in
7
- `~/Projects/dotfiles/docs/ORLY_ARCHITECTURE.md`.
7
+ [`docs/ORLY_ARCHITECTURE.md`](../docs/ORLY_ARCHITECTURE.md).
8
8
  The deterministic checker is `make audit`. The comprehension questionnaire is
9
9
  [`audits/agents-md.md`](../audits/agents-md.md).
10
10
 
@@ -35,22 +35,25 @@ into the tree; on 🟠 run `provision-env-1password`, re-link. Post-merge:
35
35
 
36
36
  **agentsfleet CHORE(close) paths:** the `<Update>` lands in
37
37
  `~/Projects/docs/changelog.mdx` (template + version-bump matrix:
38
- `skills/release-template.md` in the governance checkout — re-source each
39
- release, never paraphrase). Version sync: `VERSION` touched → `make
38
+ `docs/RELEASE_TEMPLATE.md` in this repository — re-source each release, never
39
+ paraphrase). Version sync: `VERSION` touched → `make
40
40
  sync-version`; commit propagated `build.zig.zon` / `agentsfleet/package.json` /
41
41
  `agentsfleet/src/cli.js`; `make check-version` passes.
42
42
 
43
- **dotfiles takes NO worktree — feature branches in the main checkout.** Every
44
- agent-home symlink and every consumer's `ORLY_ROOT` resolves to
45
- `~/Projects/dotfiles`; a linked worktree's `.git` is a *file*, and the audit
46
- chain the hooks run spawns git against other directories. Under a hook that
47
- combination corrupted the real index and flipped `core.bare` (Aug 16, 2026).
48
- So: `git checkout -b feat/mNN-name` in `~/Projects/dotfiles` itself, and
49
- `orly update` always renders the one true root. One stream at a time —
50
- a second concurrent agent coordinates in-session rather than forking a tree.
51
- This is the dotfiles exception; product repos keep the worktree recipe above.
52
43
  <!-- oracle-packs:end -->
53
44
 
45
+ **A governance/rules repository takes NO linked worktree — cut feature branches
46
+ in its main checkout.** This applies to whichever repository owns the rules you
47
+ are editing (the orly pack source, a dotfiles checkout, any repo whose own hooks
48
+ run the governance audit chain). A linked worktree's `.git` is a *file*, and that
49
+ audit chain spawns git against other directories; under a hook the combination
50
+ corrupted the real index and flipped `core.bare` (Aug 16, 2026). So:
51
+ `git checkout -b feat/mNN-name` in the rules repository's main checkout, and let
52
+ `orly update` re-materialise consumers from there. One stream at a time — a
53
+ second concurrent agent coordinates in-session rather than forking a tree.
54
+ This is the governance-repo exception; product repos still create the worktree
55
+ at CHORE(open).
56
+
54
57
  **Mid-stream spec → ask before hydrating (default: same tree).** A spec created
55
58
  inside an active worktree → ask the user before spinning up a second one. Lean
56
59
  same tree: a second tree fragments the outcome and adds a PR to babysit.
@@ -58,7 +58,7 @@ If the change is internal-only, emit instead:
58
58
  ## Family
59
59
 
60
60
  - `docs/CHANGELOG_VOICE.md` — the canonical Mintlify-style voice rules (deep reference).
61
- - `skills/release-template.md` — release template + version-bump matrix; re-source
61
+ - `docs/RELEASE_TEMPLATE.md` — release template + version-bump matrix; re-source
62
62
  each release, never paraphrase.
63
63
  - `verify` — runs before CHORE(close); a changelog claim must be backed by the
64
64
  verification that dispatch reports.
@@ -4,14 +4,16 @@
4
4
  > any narrower guide such as `CHANGELOG_VOICE.md` or before editing documentation
5
5
  > about the dispatch model.
6
6
 
7
- These rules govern customer-facing `agentsfleet` documentation. They keep pages
7
+ These rules govern any documentation orly writes or renders. They keep pages
8
8
  short, accurate, and usable by a first-day reader. Compliance is binary. A
9
9
  rejection cites the Documentation (DOC) rule identifier that failed.
10
10
 
11
11
  ## Scope and precedence
12
12
 
13
- - **DOC-S1 — Published pages.** A standalone Markdown JSX (MDX) file listed in
14
- `docs.json` follows every applicable rule in this document.
13
+ - **DOC-S1 — Any page orly writes.** Every Markdown or Markdown JSX (MDX) file
14
+ orly writes, renders, or materialises follows every applicable rule in this
15
+ document. A published page listed in `docs.json`, a rule page installed into
16
+ a repository, a skill page, and a `README.md` are all in scope.
15
17
  - **DOC-S2 — Generated API pages.** OpenAPI source follows the reference-page
16
18
  intent through summaries, descriptions, parameters, examples, responses, and
17
19
  stable errors. Generated pages do not need MDX front matter.
@@ -26,6 +28,9 @@ rejection cites the Documentation (DOC) rule identifier that failed.
26
28
  - **DOC-S6 — Source wins.** Runtime code is the source of truth. OpenAPI and the
27
29
  command-line interface follow runtime behavior. Published pages follow all
28
30
  three.
31
+ - **DOC-S7 — Records stay as written.** A historical record keeps its original
32
+ wording. Spec records under `docs/v1/` and closed changelog entries are read,
33
+ never rewritten to a later rule.
29
34
 
30
35
  ## Required front matter
31
36
 
@@ -15,7 +15,7 @@ Triggers on every `Edit`/`Write` that adds, removes, or changes a log emit:
15
15
  Out of scope (explicitly):
16
16
 
17
17
  - Test-only diagnostic prints inside `*_test.zig`, `*.test.ts`, `*.spec.ts`. Tests render to humans, not collectors; gates ignore.
18
- - Build/release scripts in `~/Projects/dotfiles/`, `audits/release-*` — toolchain output, not application logs.
18
+ - Build/release scripts — the harness's own `audits/*`, `audits/release-*`, and any governance-repo tooling — toolchain output, not application logs.
19
19
  - Generated framework noise (Next.js startup banners, Bun runtime warnings) — out of our control.
20
20
 
21
21
  The **LOGGING GATE** (`dispatch/write_any.md`, Logging Gate) sits on top of this file — it fires in addition to the language-level rules in `dispatch/write_zig.md` and `dispatch/write_ts_adhere_bun.md`, not instead of them.
@@ -48,8 +48,8 @@ scripts resolved live from this checkout via `$ORLY_ROOT`.
48
48
 
49
49
  That traded the storage cost for a distribution cost undiscovered until a
50
50
  second engineer tried to install the harness without this checkout present —
51
- the symlink and `$ORLY_ROOT` both require a copy of `~/Projects/dotfiles` at a
52
- known path, which is exactly what a fresh machine, a Continuous Integration
51
+ the symlink and `$ORLY_ROOT` both require a copy of the governance checkout at
52
+ a known absolute path, which is exactly what a fresh machine, a Continuous Integration
53
53
  (CI) runner, or a remote fleet container does not have. `orly init` (M03)
54
54
  restores storage, but not the failure mode that got it removed: `orly update`
55
55
  turns a rule change into one command per repository instead of the manual
@@ -0,0 +1,46 @@
1
+ # Release doc template — `changelog.mdx` `<Update>` block
2
+
3
+ Source of truth: `~/Projects/docs/changelog.mdx`. New `<Update>` blocks go at the top, after the leading `<Tip>`/`<Note>`. Labels are date-only — never a semver prefix. `VERSION` is the single source of truth for binary version (binaries via `make sync-version` propagate to `build.zig.zon`, `agentsfleet/package.json`, `agentsfleet/src/cli.js`); changelog stays chronological. Decoupled by design — avoids parallel-branch collisions.
4
+
5
+ ## Block template
6
+
7
+ ```mdx
8
+ <Update label="MMM DD, YYYY" tags={["What's new" | "Breaking" | "Bug fixes", "API" | "CLI" | "UI" | "Security" | "Performance" | "Integrations" | "Observability" | "Internal", ...]}>
9
+ ## {Short user-facing feature title — no milestone IDs, no codenames}
10
+
11
+ {One paragraph from the user's perspective. No workstream numbers, branch names, RULE references.}
12
+
13
+ ## Upgrading
14
+ {Breaking changes only. ALWAYS first when present. Each break: explicit migration step + whether CLI+server must upgrade together.}
15
+
16
+ ## What's new
17
+ {New capabilities — what a user/operator can now do.}
18
+
19
+ ## API reference
20
+ {New/changed endpoints, shapes, error codes. Include JSON/route examples. Omit if no API change.}
21
+
22
+ ## Bug fixes
23
+ {User-visible bugs fixed — observed behavior before/after. Omit if none.}
24
+
25
+ ## CLI
26
+ {`agentsfleet` additions or shape changes. Omit if none.}
27
+ </Update>
28
+ ```
29
+
30
+ ## Hard rules
31
+
32
+ - Label is `MMM DD, YYYY` exactly — no `vX.Y.Z —` prefix, no release name. Two releases on the same date → one merged `<Update>` block OR a disambiguator inside the title (`## Morning release — …`, `## Follow-up — …`); the label stays the date.
33
+ - Section order is fixed: Upgrading → What's new → API reference → Bug fixes → CLI. Omit empty sections; never leave empty headings.
34
+ - No milestone/workstream IDs, branch names, spec filenames, or `RULE XXX` references in the body.
35
+ - User-centric verbs ("we added", "X now does Y"), not implementation prose.
36
+ - Every breaking change appears under `Upgrading` with a migration step, even if also mentioned elsewhere.
37
+ - Body copy may reference a past entry by date (`"…that shipped on Apr 22, 2026"`); do not reference past releases by semver (`"shipped in v0.27.0"`) — that drags the two timelines back together.
38
+
39
+ ## Version bumps (`VERSION`, not the changelog label)
40
+
41
+ - Feature milestone → minor (`0.7.0` → `0.8.0`).
42
+ - Bug fix → patch.
43
+ - Pre-v1.0 breaking → minor (semver 0.x carve-out); call out under Upgrading.
44
+ - Post-v1.0 breaking → major.
45
+ - Internal-only refactor: terse `<Update>` with `tags={["Internal", ...]}`, one-paragraph summary, skip section structure. Prefer folding into the next user-visible release.
46
+ - Parallel branches bumping `VERSION` do not coordinate through the changelog — whichever lands second rebases `VERSION` and re-runs `make sync-version`.
@@ -9,12 +9,12 @@ regenerates this file and fails on any difference. To update it, run
9
9
  | `docs/LOGGING_STANDARD.md` | `LOG` → `logging.sh` | 10 | 13 | 26/26 | mechanical |
10
10
  | `docs/REST_API_DESIGN_GUIDELINES.md` | — | 0 | 0 | 0/74 | mechanical |
11
11
  | `docs/SCHEMA_CONVENTIONS.md` | — | 0 | 0 | 0/16 | mechanical |
12
- | `docs/DOCUMENTATION_RULES.md` | — | 0 | 0 | 0/20 | latent |
12
+ | `docs/DOCUMENTATION_RULES.md` | — | 0 | 0 | 0/21 | latent |
13
13
  | `docs/LIFECYCLE_PATTERNS.md` | — | 0 | 0 | 0/22 | mechanical |
14
14
  | `docs/CHANGELOG_VOICE.md` | — | 0 | 0 | 0/3 | latent |
15
15
  | `docs/VERIFY_TIERS.md` | — | 0 | 0 | 0/4 | uncited |
16
16
  | `docs/greptile-learnings/RULES.md` | — | 0 | 0 | 0/113 | mechanical |
17
- | **corpus** | | | | **26/278** | |
17
+ | **corpus** | | | | **26/279** | |
18
18
 
19
19
  ## How to read this
20
20
 
@@ -0,0 +1,29 @@
1
+ # Slop fixture
2
+
3
+ This page is the fail fixture for the documentation-language check. Every
4
+ violation below is deliberate, and the test asserts the check finds each one.
5
+
6
+ We leverage a robust and seamless pipeline to execute the request, and the
7
+ result is a powerful experience that lets you delve into a realm of insight
8
+ without ever once thinking about the mechanics of the thing you are using.
9
+ This sentence exists to be long. This one too. And a fourth, to break DOC-03.
10
+
11
+ An em dash — in short copy — is over budget by itself.
12
+
13
+ ### Skipped level
14
+
15
+ The heading above jumps from H1 to H3.
16
+
17
+ # Second level-one heading
18
+
19
+ `make harness-verify` and `provision-env-1password` are command names, not
20
+ banned words. The check must leave both alone.
21
+
22
+ | leverage | robust | seamless |
23
+ |---|---|---|
24
+ | a table cell is exempt | so is this | and this |
25
+
26
+ ```bash
27
+ # a fenced block is exempt: execute the robust and seamless pipeline
28
+ orly gate verify
29
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentsfleet/orly",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Repository-scoped engineering harness: renders agent rules, materialises the gates that enforce them, and proves the PR boundary — for any coding agent.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/registry.json CHANGED
@@ -228,6 +228,10 @@
228
228
  {
229
229
  "source": "docs/CHANGELOG_VOICE.md",
230
230
  "target": "docs/CHANGELOG_VOICE.md"
231
+ },
232
+ {
233
+ "source": "docs/RELEASE_TEMPLATE.md",
234
+ "target": "docs/RELEASE_TEMPLATE.md"
231
235
  }
232
236
  ]
233
237
  },
@@ -32,7 +32,6 @@ Before doing anything else in a polling cycle, read the triage helper:
32
32
 
33
33
  ```bash
34
34
  GREPTILE_TRIAGE="$HOME/.local/share/gstack/review/greptile-triage.md"
35
- [ -r "$GREPTILE_TRIAGE" ] || GREPTILE_TRIAGE="$HOME/Projects/dotfiles/.unified-skills/review/greptile-triage.md"
36
35
  [ -r "$GREPTILE_TRIAGE" ] || { echo "BABYSIT: greptile-triage.md missing — abort cycle"; exit 1; }
37
36
  ```
38
37
 
@@ -312,15 +311,14 @@ BABYSIT REPORT — <PR|MR> #<n> @ <SHA>
312
311
  ## References
313
312
 
314
313
  - `~/.local/share/gstack/review/greptile-triage.md` — fetch / classify /
315
- reply / suppressions / history-write mechanics. Also at
316
- `~/Projects/dotfiles/.unified-skills/review/greptile-triage.md`.
314
+ reply / suppressions / history-write mechanics (installed by gstack).
317
315
  - `$HOME/.gstack/projects/<slug>/greptile-history.md` — per-project
318
316
  history; drives Suppressions Check.
319
317
  - `$HOME/.gstack/greptile-history.md` — global aggregate.
320
318
  - `docs/greptile-learnings/RULES.md` — project-level named rules
321
319
  (durable principles). New rule added here only when the finding
322
320
  generalizes; per-incident rows go to greptile-history.md.
323
- - `~/Projects/dotfiles/AGENTS.md` — CHORE(close) skill chain step 4
324
- cites this skill.
321
+ - this repository's `AGENTS.md` / `AGENTS.orly.md` — CHORE(close) skill
322
+ chain step 4 cites this skill.
325
323
  - `gh api` docs: https://cli.github.com/manual/gh_api
326
324
  - `glab api` docs: https://gitlab.com/gitlab-org/cli (`glab api --help`)
package/src/criteria.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  openQuestions, productClarity, specBaseline, specDeferrals, specDimensions,
9
9
  specGate, specMoved, specOrdering,
10
10
  } from "./criteria_spec";
11
+ import { documentationSurfaces, scanSurfaces } from "./doc_rules";
11
12
  import { isObject, JsonObject, objectValue, OrlyError, RulesModel } from "./model";
12
13
  import { readConfigSync, RepoConfig } from "./config";
13
14
  import { classifyBranch, SurfaceReport } from "./surfaces";
@@ -30,6 +31,10 @@ const GIT_TREE = "git.tree";
30
31
  const GIT_PUSHED = "git.pushed";
31
32
  const REPO_CONFIG = "repo.config";
32
33
  const DOCS_UPDATED = "docs.updated";
34
+ const DOCS_LANGUAGE = "docs.language";
35
+ // How many findings a report names before it stops listing them. A criterion
36
+ // line is a summary, not a linter dump.
37
+ const NAMED_FINDINGS = 3;
33
38
 
34
39
  const FAST_TIER = "fast";
35
40
  const SLOW_TIER = "slow";
@@ -44,7 +49,7 @@ const SLOW_COMMANDS = ["verify.integration", "verify.memory"];
44
49
  // pr = can this ship (whole-branch checks + the slow suites).
45
50
  export function criteriaFor(gate: string, context: CriterionContext): Criterion[] {
46
51
  if (gate === "work") return [gitBranch(), gitTree(), repositoryConfig()];
47
- if (gate === "verify") return [specDimensions(), ...commandCriteria(context, FAST_TIER)];
52
+ if (gate === "verify") return [specDimensions(), docsLanguage(), ...commandCriteria(context, FAST_TIER)];
48
53
  if (gate === "pr") {
49
54
  return [
50
55
  gitTree(), gitPushed(), specGate(), openQuestions(), productClarity(), specDimensions(),
@@ -139,6 +144,34 @@ function docsUpdated(): Criterion {
139
144
  });
140
145
  }
141
146
 
147
+ // docs/DOCUMENTATION_RULES.md, read back against the pages orly renders here.
148
+ // The scope rule is the document's own: any documentation orly writes or
149
+ // renders, README.md included.
150
+ //
151
+ // REPORTING ONLY, and deliberately so. The rule document landed against a
152
+ // corpus that predates it, and the corpus does not obey it yet: this
153
+ // repository alone carries hundreds of pre-existing findings. A red gate on
154
+ // day one would be switched off by the end of the week, and grandfathering
155
+ // them into a baseline file would be a list of chores wearing a gate's badge.
156
+ // So the check reports what it found and leaves the verdict green until a
157
+ // human has decided how the debt gets paid. Partial mechanization stated
158
+ // honestly beats a red that means nothing (the same call audits/doc-read.sh
159
+ // makes when its record is missing). Flipping this to a gate is a one-line
160
+ // change: return ok on `findings.length === 0`.
161
+ function docsLanguage(): Criterion {
162
+ return criterion(DOCS_LANGUAGE, (context) => {
163
+ const surfaces = documentationSurfaces(context.root);
164
+ if (surfaces.length === 0) return { ok: true, detail: "no documentation surface here" };
165
+ const findings = scanSurfaces(context.root, surfaces);
166
+ if (findings.length === 0) return { ok: true, detail: `${surfaces.length} page(s) clean` };
167
+ const named = findings.slice(0, NAMED_FINDINGS).map((finding) => `${finding.file}:${finding.line} ${finding.code}`);
168
+ return {
169
+ ok: true,
170
+ detail: `${findings.length} finding(s) across ${surfaces.length} page(s), reporting only: ${named.join(", ")}`,
171
+ };
172
+ });
173
+ }
174
+
142
175
  // The branch is classified once per inspection and cached on the context.
143
176
  function report(context: CriterionContext, surfaces: JsonObject | undefined): SurfaceReport {
144
177
  context.surfaces ??= classifyBranch(context.root, surfaces);
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+
5
+ import { DOC_DASH, DOC_HEADING, DOC_PARAGRAPH, DOC_SENTENCE, DOC_WORD, documentationSurfaces, scanDocument } from "./doc_rules";
6
+
7
+ const ROOT = resolve(import.meta.dir, "..");
8
+ const SLOP_FIXTURE = "fixtures/doc-slop.md";
9
+ const PAGE = "page.md";
10
+
11
+ // The pass fixture: one clean page written to the rules it is checked against.
12
+ // Every line here is prose the check must leave alone, so a false positive
13
+ // anywhere in the reader turns this test red.
14
+ const CLEAN = `# A clean page
15
+
16
+ orly writes this page. The check reads it and finds nothing.
17
+
18
+ ## What it leaves alone
19
+
20
+ Command names keep their banned word: run \`make harness-verify\` first.
21
+ A link keeps its text and drops its [target](https://example.com/execute).
22
+
23
+ | leverage | robust |
24
+ |---|---|
25
+ | a table cell is exempt | so is this |
26
+
27
+ \`\`\`bash
28
+ # fenced code is exempt: execute the robust pipeline
29
+ orly gate verify
30
+ \`\`\`
31
+
32
+ The EXECUTE stage is a name, not a verb. A rule may quote “leverage” to ban it.
33
+
34
+ <!-- lint-ignore: DOCWORD — this paragraph names the banned words to ban them -->
35
+ Also banned: delve, foster, elevate, harness, tapestry, realm.
36
+ `;
37
+
38
+ function codes(text: string): string[] {
39
+ return scanDocument(PAGE, text).map((finding) => finding.code);
40
+ }
41
+
42
+ describe("scanDocument", () => {
43
+ test("passes a clean page", () => {
44
+ expect(scanDocument(PAGE, CLEAN)).toEqual([]);
45
+ });
46
+
47
+ test("fails the slop fixture on every mechanical rule", () => {
48
+ const found = codes(readFileSync(join(ROOT, SLOP_FIXTURE), "utf8"));
49
+
50
+ expect(found).toContain(DOC_WORD);
51
+ expect(found).toContain(DOC_DASH);
52
+ expect(found).toContain(DOC_SENTENCE);
53
+ expect(found).toContain(DOC_PARAGRAPH);
54
+ expect(found).toContain(DOC_HEADING);
55
+ });
56
+
57
+ test("names the banned word it found", () => {
58
+ const findings = scanDocument(PAGE, "# Page\n\nWe leverage it.\n");
59
+
60
+ expect(findings).toHaveLength(1);
61
+ expect(findings[0]?.message).toContain("leverage");
62
+ expect(findings[0]?.line).toBe(3);
63
+ });
64
+
65
+ test("counts an over-budget page by its overage", () => {
66
+ const findings = scanDocument(PAGE, "# Page\n\nOne — two — three.\n").filter((finding) => finding.code === DOC_DASH);
67
+
68
+ expect(findings[0]?.weight).toBe(2);
69
+ });
70
+
71
+ test("reads a lowercase product name as a sentence start", () => {
72
+ const paragraph = "# Page\n\nOne here. orly two. bun three. git four.\n";
73
+
74
+ expect(codes(paragraph)).toEqual([DOC_PARAGRAPH]);
75
+ });
76
+
77
+ test("leaves a hyphenated command name alone", () => {
78
+ expect(codes("# Page\n\nRun provision-env-1password before the hook.\n")).toEqual([]);
79
+ });
80
+
81
+ test("counts a code span as one word, not as a hole", () => {
82
+ const findings = scanDocument(PAGE, "# Page\n\nThe hooks run on every commit and push. `orly gate pr` then refuses it.\n");
83
+
84
+ expect(findings).toEqual([]);
85
+ });
86
+
87
+ test("requires exactly one level-one heading", () => {
88
+ expect(codes("## Only a level two\n\nProse.\n")).toContain(DOC_HEADING);
89
+ expect(codes("# One\n\nProse.\n\n# Two\n\nMore prose.\n")).toContain(DOC_HEADING);
90
+ });
91
+ });
92
+
93
+ describe("documentationSurfaces", () => {
94
+ test("covers the readme and the pages orly renders", () => {
95
+ const surfaces = documentationSurfaces(ROOT);
96
+
97
+ expect(surfaces).toContain("README.md");
98
+ expect(surfaces).toContain("docs/DOCUMENTATION_RULES.md");
99
+ expect(surfaces.every((relative) => relative.endsWith(".md"))).toBeTrue();
100
+ });
101
+ });
@@ -0,0 +1,358 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ // Mechanical enforcement of docs/DOCUMENTATION_RULES.md over the markdown orly
5
+ // writes and renders.
6
+ //
7
+ // It lives in the CLI, not in this repository's audits/, because the gap it
8
+ // closes is in what orly RENDERS. A shell script here would only ever guard
9
+ // this checkout; a criterion in the engine travels with every install and
10
+ // reads the pages wherever they land.
11
+ //
12
+ // What it enforces, mechanically and without judgment:
13
+ // DOCWORD DOC-05 + DOC-07 + DOC-14b banned vocabulary, exact forms only.
14
+ // DOCDASH DOC-14b em-dash budget: none in short copy, two per long page.
15
+ // DOCSENT DOC-02 prose sentences carry at most 25 words.
16
+ // DOCPARA DOC-03 paragraphs carry at most three sentences.
17
+ // DOCHEAD DOC-25 one level-one heading, and no skipped heading level.
18
+ //
19
+ // What it deliberately leaves alone: DOC-06 (meaning survives the shorter
20
+ // form), DOC-12 (pronoun referents), DOC-34 (review checks), and the
21
+ // reading-joy set DOC-36..DOC-42. Each needs a reader who understands the
22
+ // page. DOC-33 and DOC-34 already split the rules that way, and faking the
23
+ // judgment half would report confidence nobody has.
24
+
25
+ export const DOC_WORD = "DOCWORD";
26
+ export const DOC_DASH = "DOCDASH";
27
+ export const DOC_SENTENCE = "DOCSENT";
28
+ export const DOC_PARAGRAPH = "DOCPARA";
29
+ export const DOC_HEADING = "DOCHEAD";
30
+
31
+ export type DocFinding = { file: string; line: number; code: string; message: string; weight: number };
32
+
33
+ const MAX_SENTENCE_WORDS = 25;
34
+ const MAX_PARAGRAPH_SENTENCES = 3;
35
+ // "Short copy" and "long page" need a bound to be mechanical. A page under
36
+ // this many words of prose is short copy and gets no em dash at all.
37
+ const SHORT_COPY_WORDS = 300;
38
+ const LONG_PAGE_DASHES = 2;
39
+ const EM_DASH = "—";
40
+ const README = "README.md";
41
+ const CONFIG_RELATIVE_PATH = ".oracle/orly.json";
42
+ const REGISTRY_FILE = "registry.json";
43
+ const CODE_PLACEHOLDER = "Code";
44
+ const EMPTY = "";
45
+ const CLOSERS = "]\")'";
46
+ const ENDERS = ".!?";
47
+ const SPACE = " ";
48
+ const UTF8 = "utf8";
49
+ const FRONT_MATTER_FENCE = "---";
50
+ // The first capture group, kept when a markdown span is unwrapped.
51
+ const FIRST_GROUP = "$1";
52
+
53
+ // Enumerated, never stemmed: a stemmer that turns "execute" into "executable"
54
+ // bans a correct English word, and one false positive is what gets a check
55
+ // switched off. The inflections that carry the same slop are listed by hand.
56
+ const BANNED_WORDS = new Set([
57
+ "utilise", "utilises", "utilised", "utilising", "utilisation",
58
+ "utilize", "utilizes", "utilized", "utilizing", "utilization",
59
+ "facilitate", "facilitates", "facilitated", "facilitating",
60
+ "leverage", "leverages", "leveraged", "leveraging",
61
+ "orchestrate", "orchestrates", "orchestrated", "orchestrating", "orchestration",
62
+ "instantiate", "instantiates", "instantiated", "instantiating", "instantiation",
63
+ "terminate", "terminates", "terminated", "terminating", "termination",
64
+ "provision", "provisions", "provisioned", "provisioning",
65
+ "execute", "executes", "executed", "executing", "execution", "executions",
66
+ "persist", "persists", "persisted", "persisting", "persistence",
67
+ "hydrate", "hydrates", "hydrated", "hydrating", "hydration",
68
+ "artifact", "artifacts",
69
+ "powerful", "revolutionary", "enterprise-grade", "seamless", "seamlessly",
70
+ "cutting-edge", "robust", "next-generation", "world-class", "intelligent",
71
+ "delve", "delves", "delved", "delving",
72
+ "foster", "fosters", "fostered", "fostering",
73
+ "elevate", "elevates", "elevated", "elevating",
74
+ "harness", "harnesses", "harnessed", "harnessing",
75
+ "supercharge", "supercharges", "supercharged", "supercharging",
76
+ "embark", "embarks", "embarked", "embarking",
77
+ "ever-evolving", "tapestry", "realm", "realms",
78
+ ]);
79
+
80
+ const BANNED_PHRASES = ["paradigm shift", "game changer", "game-changer"];
81
+
82
+ // Forms that end in a period without ending a sentence.
83
+ const ABBREVIATIONS = new Set(["e.g.", "i.e.", "etc.", "vs.", "cf.", "no.", "fig.", "approx."]);
84
+ // Product names are written lowercase and open sentences all over these pages.
85
+ // Without this list "…host. orly neither…" reads as one sentence and both
86
+ // length rules quietly under-report. The list is closed on purpose: guessing
87
+ // at lowercase sentence starts in general would split mid-sentence and invent
88
+ // violations.
89
+ const LOWERCASE_OPENERS = new Set(["orly", "gstack", "agentsfleet", "bun", "bunx", "git", "npm"]);
90
+
91
+ // Which DOC identifiers each code stands for, so a `lint-ignore` may name
92
+ // either the code or the rule it enforces.
93
+ const RULE_NAMES: Record<string, string> = {
94
+ [DOC_WORD]: "DOC-05 DOC-07 DOC-14b",
95
+ [DOC_DASH]: "DOC-14b",
96
+ [DOC_SENTENCE]: "DOC-02",
97
+ [DOC_PARAGRAPH]: "DOC-03",
98
+ [DOC_HEADING]: "DOC-25",
99
+ };
100
+
101
+ // The pages orly renders into a repository, plus its readme. The materialised
102
+ // set is authoritative and it is recorded, not guessed: `.oracle/orly.json`
103
+ // names every file orly wrote here. In the engine's own checkout the registry
104
+ // names the sources those files are rendered from, which is the same set one
105
+ // step earlier. README.md joins either list by the scope rule in
106
+ // docs/DOCUMENTATION_RULES.md — the rules cover any documentation orly writes.
107
+ export function documentationSurfaces(root: string): string[] {
108
+ const found = new Set<string>();
109
+ for (const path of [join(root, REGISTRY_FILE), join(root, CONFIG_RELATIVE_PATH)]) {
110
+ if (!existsSync(path)) continue;
111
+ for (const match of readFileSync(path, UTF8).matchAll(/"([^"\n]+\.md)"/g)) {
112
+ const relative = match[1] ?? EMPTY;
113
+ if (!relative.startsWith("/") && !relative.includes("..")) found.add(relative);
114
+ }
115
+ }
116
+ found.add(README);
117
+ return [...found].filter((relative) => existsSync(join(root, relative))).sort();
118
+ }
119
+
120
+ export function scanSurfaces(root: string, surfaces: string[]): DocFinding[] {
121
+ return surfaces.flatMap((relative) => scanDocument(relative, readFileSync(join(root, relative), UTF8)));
122
+ }
123
+
124
+ export function scanDocument(file: string, text: string): DocFinding[] {
125
+ return new Reader(file, text).run();
126
+ }
127
+
128
+ type Paragraph = { text: string; line: number; ignore: string };
129
+
130
+ class Reader {
131
+ private readonly findings: DocFinding[] = [];
132
+ private paragraph: Paragraph = { text: EMPTY, line: 0, ignore: EMPTY };
133
+ // A pragma introduces the paragraph under it, so it outlives its own line
134
+ // and dies with the paragraph it was written for.
135
+ private pendingIgnore = EMPTY;
136
+ private readonly declared = new Set<string>();
137
+ private proseWords = 0;
138
+ private dashes = 0;
139
+ private headingLevels: number[] = [];
140
+ private inComment = false;
141
+ private inFence = false;
142
+ private inFrontMatter = false;
143
+
144
+ constructor(private readonly file: string, private readonly text: string) {}
145
+
146
+ run(): DocFinding[] {
147
+ const lines = this.text.split(/\r?\n/);
148
+ for (const [index, raw] of lines.entries()) this.read(raw, index + 1);
149
+ this.flush();
150
+ this.reportHeadings();
151
+ this.reportDashes();
152
+ return this.findings;
153
+ }
154
+
155
+ private read(raw: string, lineNumber: number): void {
156
+ if (lineNumber === 1 && raw === FRONT_MATTER_FENCE) {
157
+ this.inFrontMatter = true;
158
+ return;
159
+ }
160
+ if (this.inFrontMatter) {
161
+ if (raw === FRONT_MATTER_FENCE) this.inFrontMatter = false;
162
+ return;
163
+ }
164
+ if (/^[ \t]*(```|~~~)/.test(raw)) {
165
+ this.inFence = !this.inFence;
166
+ this.flush();
167
+ return;
168
+ }
169
+ if (this.inFence) return;
170
+ if (this.inComment) {
171
+ if (raw.includes("-->")) this.inComment = false;
172
+ return;
173
+ }
174
+ // A comment that opens and closes on one line is cut out of the line, not
175
+ // used to drop it: a prose line ending in a pack marker is still prose.
176
+ // One comment is read rather than dropped, `lint-ignore: <CODE> — reason`,
177
+ // the spelling DOC-35 already gives an exception. It clears that code for
178
+ // the paragraph it introduces, and it is how a page that must NAME a
179
+ // banned word says so out loud instead of silently.
180
+ const ignore = /lint-ignore:[ \t]*(DOC[A-Z0-9-]*)/.exec(raw)?.[1] ?? EMPTY;
181
+ if (ignore) {
182
+ this.declared.add(ignore);
183
+ this.pendingIgnore = ignore;
184
+ }
185
+ const active = this.pendingIgnore;
186
+ const line = raw.replace(/<!--.*?-->/g, SPACE);
187
+ if (line.includes("<!--")) {
188
+ this.inComment = true;
189
+ return;
190
+ }
191
+ // Tables are exempt by DOC-13, and a cell is not a sentence.
192
+ if (/^[ \t]*\|/.test(line)) {
193
+ this.flush();
194
+ return;
195
+ }
196
+ const heading = /^(#{1,6})[ \t]+(.*)$/.exec(line);
197
+ if (heading) {
198
+ this.flush();
199
+ this.readHeading(heading[1]?.length ?? 0, clean(heading[2] ?? EMPTY), lineNumber, active);
200
+ return;
201
+ }
202
+ if (/^[ \t]*$/.test(line)) {
203
+ // Unless the line went blank because it held nothing but a lint-ignore:
204
+ // a pragma on its own line introduces the paragraph under it.
205
+ if (!ignore) this.flush();
206
+ return;
207
+ }
208
+ if (/^[ \t]*([-*+]|[0-9]+[.)])[ \t]/.test(line)) this.flush();
209
+ const prose = clean(line.replace(/^[ \t]*>[ \t]?/, EMPTY).replace(/^[ \t]*([-*+]|[0-9]+[.)])[ \t]+/, EMPTY));
210
+ if (/^[ \t]*$/.test(prose)) return;
211
+ this.scanWords(prose, lineNumber, active);
212
+ this.countDashes(prose);
213
+ if (!this.paragraph.text) this.paragraph = { text: prose, line: lineNumber, ignore: active };
214
+ else this.paragraph.text += `${SPACE}${prose}`;
215
+ }
216
+
217
+ private readHeading(level: number, title: string, lineNumber: number, ignore: string): void {
218
+ this.scanWords(title, lineNumber, ignore);
219
+ this.countDashes(title);
220
+ const previous = this.headingLevels[this.headingLevels.length - 1];
221
+ if (previous === undefined) {
222
+ if (level !== 1) this.report(lineNumber, DOC_HEADING, `first heading is H${level}, not H1`, 1, ignore);
223
+ } else if (level > previous + 1) {
224
+ this.report(lineNumber, DOC_HEADING, `heading level jumps H${previous} to H${level}`, 1, ignore);
225
+ }
226
+ this.headingLevels.push(level);
227
+ }
228
+
229
+ private scanWords(prose: string, lineNumber: number, ignore: string): void {
230
+ this.proseWords += words(prose).length;
231
+ const spoken = unquote(prose);
232
+ for (const token of words(spoken)) {
233
+ // An all-caps token is a name, not vocabulary: EXECUTE is a lifecycle
234
+ // stage, CONFORM is a gate, DOC-05 is a rule identifier. DOC-09 asks for
235
+ // one canonical name per concept, and this is how those names are written.
236
+ if (/^[A-Z0-9_.-]+$/.test(token) && /[A-Z]/.test(token)) continue;
237
+ const word = token.toLowerCase().replace(/^[./-]+/, EMPTY).replace(/[.,;:!?)/-]+$/, EMPTY);
238
+ if (BANNED_WORDS.has(word)) {
239
+ this.report(lineNumber, DOC_WORD, `banned word "${token}" (DOC-05/DOC-07/DOC-14b)`, 1, ignore);
240
+ }
241
+ }
242
+ const lowered = spoken.toLowerCase();
243
+ for (const phrase of BANNED_PHRASES) {
244
+ if (lowered.includes(phrase)) this.report(lineNumber, DOC_WORD, `banned phrase "${phrase}" (DOC-14b)`, 1, ignore);
245
+ }
246
+ }
247
+
248
+ private countDashes(prose: string): void {
249
+ this.dashes += prose.split(EM_DASH).length - 1;
250
+ }
251
+
252
+ private flush(): void {
253
+ const { text, line, ignore } = this.paragraph;
254
+ this.paragraph = { text: EMPTY, line: 0, ignore: EMPTY };
255
+ if (!text) return;
256
+ this.pendingIgnore = EMPTY;
257
+ const parts = sentences(text);
258
+ if (parts.length > MAX_PARAGRAPH_SENTENCES) {
259
+ this.report(line, DOC_PARAGRAPH, `${parts.length} sentences in one paragraph, DOC-03 allows ${MAX_PARAGRAPH_SENTENCES}`, 1, ignore);
260
+ }
261
+ for (const sentence of parts) {
262
+ const count = words(sentence).length;
263
+ if (count > MAX_SENTENCE_WORDS) {
264
+ this.report(line, DOC_SENTENCE, `${count} words in one sentence, DOC-02 allows ${MAX_SENTENCE_WORDS}: "${excerpt(sentence)}"`, 1, ignore);
265
+ }
266
+ }
267
+ }
268
+
269
+ private reportHeadings(): void {
270
+ const first = this.headingLevels.filter((level) => level === 1).length;
271
+ if (first === 0) this.reportPage(DOC_HEADING, "no level-one heading", 1);
272
+ if (first > 1) this.reportPage(DOC_HEADING, `${first} level-one headings, DOC-25 allows one`, first - 1);
273
+ }
274
+
275
+ private reportDashes(): void {
276
+ const short = this.proseWords < SHORT_COPY_WORDS;
277
+ const budget = short ? 0 : LONG_PAGE_DASHES;
278
+ if (this.dashes <= budget) return;
279
+ const where = short ? `short copy (${this.proseWords} prose words), budget 0` : `a long page, budget ${budget}`;
280
+ this.reportPage(DOC_DASH, `${this.dashes} em dashes in ${where}`, this.dashes - budget);
281
+ }
282
+
283
+ // `weight` is how much debt a finding carries: one for a line-level
284
+ // violation, the overage for a page-level budget. A page five dashes over
285
+ // and a page one dash over are not the same finding.
286
+ private report(line: number, code: string, message: string, weight: number, ignore: string): void {
287
+ if (ignore && allows(ignore, code)) return;
288
+ this.findings.push({ file: this.file, line, code, message, weight });
289
+ }
290
+
291
+ private reportPage(code: string, message: string, weight: number): void {
292
+ for (const declared of this.declared) if (allows(declared, code)) return;
293
+ this.findings.push({ file: this.file, line: 0, code, message, weight });
294
+ }
295
+ }
296
+
297
+ function allows(ignore: string, code: string): boolean {
298
+ return ignore === code || (RULE_NAMES[code] ?? EMPTY).includes(ignore);
299
+ }
300
+
301
+ function words(prose: string): string[] {
302
+ return prose.replace(/[^A-Za-z0-9_/.-]+/g, SPACE).split(/[ \t]+/).filter(Boolean);
303
+ }
304
+
305
+ // Everything the rules exempt comes out before any rule reads the text. An
306
+ // inline code span becomes a placeholder word rather than a hole: a hole loses
307
+ // the capital that starts the next sentence, and the splitter then welds two
308
+ // sentences into one long false positive. A link keeps its text and drops its
309
+ // target, because the text is prose and the target is a path no rule governs.
310
+ function clean(line: string): string {
311
+ return line
312
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, SPACE)
313
+ .replace(/`[^`]*`/g, CODE_PLACEHOLDER)
314
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, FIRST_GROUP)
315
+ .replace(/\[([^\]]*)\]\[[^\]]*\]/g, FIRST_GROUP)
316
+ .replace(/https?:\/\/[^\s)>]+/g, SPACE)
317
+ .replace(/<[^>]*>/g, SPACE);
318
+ }
319
+
320
+ // Use versus mention: a word inside quotation marks is being named, not used.
321
+ // A rule that bans a word has to print it to ban it, and a page quoting a
322
+ // caller is reporting speech rather than writing it.
323
+ function unquote(prose: string): string {
324
+ return prose.replace(/“[^”]*”/g, SPACE).replace(/"[^"]*"/g, SPACE);
325
+ }
326
+
327
+ function excerpt(sentence: string): string {
328
+ const limit = 57;
329
+ return sentence.length > limit + 3 ? `${sentence.slice(0, limit)}...` : sentence;
330
+ }
331
+
332
+ // A period, question mark, or exclamation mark, then optional closing brackets
333
+ // or quotes, then a space, then the next sentence. What follows the space
334
+ // decides: a lowercase letter, a digit, or more punctuation continues the
335
+ // sentence, unless the word is one of the lowercase product names.
336
+ function sentences(paragraph: string): string[] {
337
+ const parts: string[] = [];
338
+ let current = EMPTY;
339
+ for (let index = 0; index < paragraph.length; index += 1) {
340
+ const character = paragraph[index] ?? EMPTY;
341
+ current += character;
342
+ if (!ENDERS.includes(character)) continue;
343
+ while (CLOSERS.includes(paragraph[index + 1] ?? EMPTY) && index + 1 < paragraph.length) {
344
+ index += 1;
345
+ current += paragraph[index];
346
+ }
347
+ const gap = paragraph[index + 1] ?? EMPTY;
348
+ if (gap !== SPACE && gap !== "\t" && gap !== EMPTY) continue;
349
+ const rest = paragraph.slice(index + 2);
350
+ if (gap !== EMPTY && /^[a-z0-9,;:]/.test(rest) && !LOWERCASE_OPENERS.has(/^[A-Za-z0-9_-]+/.exec(rest)?.[0] ?? EMPTY)) continue;
351
+ const previous = current.split(/[ \t]/).pop() ?? EMPTY;
352
+ if (ABBREVIATIONS.has(previous.toLowerCase()) || /^[A-Za-z]\.$/.test(previous)) continue;
353
+ parts.push(current.trim());
354
+ current = EMPTY;
355
+ }
356
+ if (current.trim()) parts.push(current.trim());
357
+ return parts;
358
+ }
package/src/gates.test.ts CHANGED
@@ -30,7 +30,7 @@ describe("gate groups", () => {
30
30
  const model = await modelFor(project);
31
31
 
32
32
  expect(names(runGate(model, project, "work"))).toEqual(["git.branch", "git.tree", "repo.config"]);
33
- expect(names(runGate(model, project, "verify"))).toEqual(["cmd.conform", "cmd.verify.unit", "spec.dimensions"]);
33
+ expect(names(runGate(model, project, "verify"))).toEqual(["cmd.conform", "cmd.verify.unit", "docs.language", "spec.dimensions"]);
34
34
  expect(names(runGate(model, project, "pr"))).toEqual([
35
35
  "docs.updated", "git.pushed", "git.tree", "spec.baseline", "spec.deferrals", "spec.dimensions", "spec.gate",
36
36
  "spec.moved", "spec.open-questions", "spec.ordering", "spec.product-clarity",
@@ -7,7 +7,10 @@ import { install } from "./install";
7
7
  import { RulesModel } from "./model";
8
8
 
9
9
  const SOURCE_EXTENSIONS = ["rs", "ts", "tsx", "js", "jsx", "py", "sh", "sql", "zig", "mdx"];
10
- const ENGINE_VERSION = "0.5.2";
10
+ // Read from package.json rather than restated here: a hand-synced copy goes
11
+ // stale at the next release and the test then proves an install at a version
12
+ // that no longer ships.
13
+ const ENGINE_VERSION = (await Bun.file(join(ROOT, "package.json")).json()).version;
11
14
 
12
15
  afterEach(cleanupTemporaryDirectories);
13
16