@agentsfleet/orly 0.5.3 → 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
 
@@ -241,9 +241,9 @@ 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.
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,7 +1,7 @@
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.
@@ -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
 
@@ -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
 
@@ -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.3",
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/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",