@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 +8 -8
- package/README.md +30 -14
- package/SOUL.md +4 -4
- package/audits/agents-md.md +1 -1
- package/audits/data.sh +4 -2
- package/core/operating-model.md +4 -4
- package/dispatch/edit_rules.md +1 -1
- package/dispatch/lifecycle.md +14 -11
- package/dispatch/write_changelog.md +1 -1
- package/docs/DOCUMENTATION_RULES.md +8 -3
- package/docs/LOGGING_STANDARD.md +1 -1
- package/docs/ORLY_ARCHITECTURE.md +2 -2
- package/docs/RELEASE_TEMPLATE.md +46 -0
- package/docs/RULE_ENFORCEMENT.md +2 -2
- package/fixtures/doc-slop.md +29 -0
- package/package.json +1 -1
- package/registry.json +4 -0
- package/skills/orly-babysit-prs/SKILL.md +3 -5
- package/src/criteria.ts +34 -1
- package/src/doc_rules.test.ts +101 -0
- package/src/doc_rules.ts +358 -0
- package/src/gates.test.ts +1 -1
- package/src/install_packs.test.ts +4 -1
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` (
|
|
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 **
|
|
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: **🦉
|
|
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
|
|
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 —
|
|
244
|
+
# SOUL.md — Tyto's working notes
|
|
245
245
|
|
|
246
|
-
> First-person:
|
|
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:
|
|
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 —
|
|
2
|
+
# SOUL.md — Tyto's working notes
|
|
3
3
|
|
|
4
|
-
> First-person:
|
|
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:
|
|
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
|
|
package/audits/agents-md.md
CHANGED
|
@@ -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
|
|
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
|
|
78
|
-
#
|
|
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"
|
package/core/operating-model.md
CHANGED
|
@@ -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` (
|
|
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 **
|
|
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: **🦉
|
|
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
|
|
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.
|
package/dispatch/edit_rules.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/dispatch/lifecycle.md
CHANGED
|
@@ -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
|
-
`
|
|
39
|
-
|
|
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
|
-
- `
|
|
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
|
|
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 —
|
|
14
|
-
|
|
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
|
|
package/docs/LOGGING_STANDARD.md
CHANGED
|
@@ -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
|
|
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
|
|
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`.
|
package/docs/RULE_ENFORCEMENT.md
CHANGED
|
@@ -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/
|
|
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/
|
|
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
|
+
"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
|
@@ -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
|
|
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
|
-
-
|
|
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
|
+
});
|
package/src/doc_rules.ts
ADDED
|
@@ -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
|
-
|
|
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
|
|