@agentsfleet/orly 0.10.0 → 0.10.2

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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
- # design-tokens.sh — enforce design-system token discipline across
3
- # ui/packages/{app,website}/**/*.tsx.
2
+ # design-tokens.sh — enforce design-system token discipline and flat fills
3
+ # across the app, website, and shared design system.
4
4
  #
5
5
  # Dispatch façade: dispatch/write_ts_adhere_bun.md (Design Tokens / DESIGN TOKEN GATE)
6
6
  # Fires in: CONFORM (via `make harness-verify` in `agentsfleet`).
@@ -62,6 +62,23 @@ PATTERNS=(
62
62
  '(text|bg|border)-(red|blue|green|yellow|purple|indigo|violet|pink|orange|gray|slate|zinc|neutral)-[0-9]:::use semantic tokens (text-destructive, text-error, text-success, …)'
63
63
  )
64
64
 
65
+ readonly GRADIENT_REGEX='(repeating-)?(linear|radial|conic)-gradient[[:space:]]*\('
66
+
67
+ # Consumers may select a font role, but only the design system defines it.
68
+ check_font_ownership() {
69
+ local f="$1"
70
+ case "$f" in ui/packages/design-system/*) return ;; esac
71
+ awk '
72
+ {
73
+ source = $0
74
+ gsub(/font-family[[:space:]]*:[[:space:]]*var\(--ff-(sans|mono|display)\)/, "")
75
+ invalid = /font-family[[:space:]]*:|fontFamily[[:space:]]*:[[:space:]]*["\047]|--(ff|font)-(sans|mono|display)[[:space:]]*:|font-\[/
76
+ if (FILENAME ~ /^ui\/packages\/app\// && /font-display|DisplayXL|DisplayLG/) invalid = 1
77
+ if (invalid) printf "%s:%d:%s\n", FILENAME, FNR, source
78
+ }
79
+ ' "$f"
80
+ }
81
+
65
82
  # ── File scope ───────────────────────────────────────────────────────────
66
83
  in_scope() {
67
84
  local f="$1"
@@ -70,8 +87,17 @@ in_scope() {
70
87
  *tests/e2e/*) return 1 ;;
71
88
  ui/packages/app/*.tsx|ui/packages/app/*.jsx) return 0 ;;
72
89
  ui/packages/website/*.tsx|ui/packages/website/*.jsx) return 0 ;;
73
- ui/packages/app/**/*.tsx|ui/packages/app/**/*.jsx) return 0 ;;
74
- ui/packages/website/**/*.tsx|ui/packages/website/**/*.jsx) return 0 ;;
90
+ *) return 1 ;;
91
+ esac
92
+ }
93
+
94
+ in_gradient_scope() {
95
+ local f="$1"
96
+ case "$f" in
97
+ *.test.*|*.spec.*|*/tests/*|*/coverage/*|*/dist/*|*/.next/*|*/node_modules/*) return 1 ;;
98
+ ui/packages/app/*.css|ui/packages/app/*.js|ui/packages/app/*.jsx|ui/packages/app/*.mjs|ui/packages/app/*.ts|ui/packages/app/*.tsx) return 0 ;;
99
+ ui/packages/website/*.css|ui/packages/website/*.js|ui/packages/website/*.jsx|ui/packages/website/*.mjs|ui/packages/website/*.ts|ui/packages/website/*.tsx) return 0 ;;
100
+ ui/packages/design-system/*.css|ui/packages/design-system/*.js|ui/packages/design-system/*.jsx|ui/packages/design-system/*.mjs|ui/packages/design-system/*.ts|ui/packages/design-system/*.tsx) return 0 ;;
75
101
  *) return 1 ;;
76
102
  esac
77
103
  }
@@ -79,12 +105,14 @@ in_scope() {
79
105
  case "$MODE" in
80
106
  --staged|staged)
81
107
  FILES=$(git diff --cached --name-only --diff-filter=ACMRT 2>/dev/null || true)
108
+ GRADIENT_CANDIDATES="$FILES"
82
109
  ;;
83
110
  --all|all)
84
111
  # `git ls-files` reports the index, which includes staged content —
85
112
  # so pre-commit-style invocations see staged-but-not-committed files
86
113
  # without needing a `--staged` flag.
87
114
  FILES=$(git ls-files -- 'ui/packages/app/*.tsx' 'ui/packages/app/*.jsx' 'ui/packages/website/*.tsx' 'ui/packages/website/*.jsx' 2>/dev/null || true)
115
+ GRADIENT_CANDIDATES=$(git ls-files -- ui/packages/app ui/packages/website ui/packages/design-system 2>/dev/null || true)
88
116
  ;;
89
117
  *)
90
118
  echo "usage: $0 [--all|--staged]" >&2
@@ -127,10 +155,34 @@ if [ "${#SCOPED_FILES[@]}" -gt 0 ]; then
127
155
  done
128
156
  fi
129
157
 
158
+ GRADIENT_FILES=()
159
+ while IFS= read -r f; do
160
+ [ -z "$f" ] && continue
161
+ [ ! -f "$f" ] && continue
162
+ in_gradient_scope "$f" || continue
163
+ GRADIENT_FILES+=("$f")
164
+ while IFS= read -r match; do
165
+ [ -z "$match" ] && continue
166
+ printf '%s\n -> define fonts in design-system tokens.css; use sans for app UI and mono for technical values\n' "$match"
167
+ SEEN_FILES_LIST="${SEEN_FILES_LIST}${f}"$'\n'
168
+ FAIL=1
169
+ done < <(check_font_ownership "$f")
170
+ done <<< "$GRADIENT_CANDIDATES"
171
+
172
+ if [ "${#GRADIENT_FILES[@]}" -gt 0 ]; then
173
+ while IFS= read -r match; do
174
+ [ -z "$match" ] && continue
175
+ f="${match%%:*}"
176
+ printf '%s\n -> use a solid design-system color token; gradients are forbidden\n' "$match"
177
+ SEEN_FILES_LIST="${SEEN_FILES_LIST}${f}"$'\n'
178
+ FAIL=1
179
+ done < <(grep -nHE "$GRADIENT_REGEX" "${GRADIENT_FILES[@]}" 2>/dev/null || true)
180
+ fi
181
+
130
182
  FILES_WITH_VIOLATIONS=$(printf '%s' "$SEEN_FILES_LIST" | sort -u | grep -c . || true)
131
183
 
132
184
  if [ "$FAIL" = "0" ]; then
133
- echo "OK: design-token discipline — no arbitraries that have a token equivalent (mode=$MODE)"
185
+ echo "OK: design-token discipline — named utilities and flat fills verified (mode=$MODE)"
134
186
  exit 0
135
187
  fi
136
188
 
@@ -40,8 +40,10 @@ a gate skip.
40
40
  names, money amounts stay verbatim.
41
41
  - **Internal cleanup** gets aggressive trimming, not a paragraph.
42
42
  - **History is archived, not rewritten** — past entries are an immutable record.
43
- - **Rate constants** stay pinned across the three files (`tenant_billing.zig`,
44
- `rates.ts`, `rates.mdx`) — a changelog money/rate claim must match all three.
43
+ - **Rate constants** are pinned by `cross_runtime_rates.rs`, which reads the
44
+ app and Command-Line Interface (CLI) mirrors against the Rust constant. A
45
+ changelog money or rate claim must match those, and `rates.mdx` in the docs
46
+ repository, which no test can reach.
45
47
 
46
48
  ## Required output (self-audit line before committing the changelog)
47
49
 
@@ -14,5 +14,5 @@ Editing `~/Projects/docs/changelog.mdx` or any other Mintlify `<Update>` block.
14
14
  - **Internal cleanup / refactor entries get the most aggressive trimming.** One lead paragraph + one bullet list. Skip "Test coverage" sections unless the test count is the headline. Indy's exact direction: *"Keep internal code cleanup, refactor to a minimal."*
15
15
  - **Never drop load-bearing facts.** Error codes (`UZ-AUTH-003`), endpoint paths + method + body shape + status code, env var names + defaults, schema column / table names, CLI subcommand + flag names, migration steps, money amounts. Tighten prose, not meaning.
16
16
  - **Historical entries are archives.** Brevity-pass them; never rewrite the past. A typo correction (e.g. `$0.001` → `$0.01` when it was never true) is allowed and must be called out in the commit message.
17
- - **Rate constants flow through three pinned files only:** `rustd/crates/afd_billing/src/nanos.rs`, `ui/packages/website/src/lib/rates.ts`, `~/Projects/docs/snippets/rates.mdx`. Bumping a rate requires a paired Pull Request (PR) across the docs repo — there is no automated guard. In `*.mdx`, use `import { STARTER_CREDIT, EVENT_RATE, STAGE_RATE } from "/snippets/rates.mdx"` instead of hand-typing `$5`.
17
+ - **Rate constants stay pinned across the runtime surfaces, and a test says so.** `rustd/crates/afd_billing/tests/cross_runtime_rates.rs` reads `ui/packages/app/lib/types.ts` and `cli/src/constants/billing.ts` and fails when either drifts from `rustd/crates/afd_billing/src/nanos.rs` — a test beside the constant it defends, not a gate nothing invokes. Public docs are the one surface it cannot reach: when a rate changes, update `~/Projects/docs/snippets/rates.mdx` in a paired Pull Request (PR). In `*.mdx`, import its named values rather than hand-typing money amounts.
18
18
  - **The Mintlify reference Indy pasted (May 1 / May 8 entries) is canonical voice.** Mirror its rhythm, not its product nouns.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentsfleet/orly",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Repository-scoped engineering harness: renders agent rules, materialises the gates that enforce them, and proves the PR boundary \u2014 for any coding agent.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
3
3
  import { tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
 
6
- import { CONFIG_PATH, managedDrift, readConfig, readConfigSync, seedConfig, selectPacks, staleVersion, writeConfig } from "./config";
6
+ import { CONFIG_PATH, contentDigest, managedDrift, readConfig, readConfigSync, seedConfig, selectPacks, staleVersion, writeConfig } from "./config";
7
7
  import { RulesModel } from "./model";
8
8
 
9
9
  const ROOT = resolve(import.meta.dir, "..");
@@ -156,6 +156,40 @@ describe("staleVersion", () => {
156
156
  });
157
157
  });
158
158
 
159
+ describe("managedDrift — content", () => {
160
+ test("a managed file edited after orly wrote it is drift", async () => {
161
+ // The gap this closes was not hypothetical: a consuming repository added a
162
+ // gradient ban and a font-ownership check straight to a materialised gate
163
+ // script, the pack never learned them, and the next update deleted them —
164
+ // with doctor green the whole way, because it asked only whether the file
165
+ // EXISTED. A gate whose checks can be removed under a passing verifier is
166
+ // not a gate.
167
+ const root = scratch();
168
+ const wrote = "check_one\ncheck_two\n";
169
+ writeFileSync(join(root, "audit.sh"), wrote);
170
+ const config = { ...(await seedConfig(root)), managed: ["audit.sh"], digests: { "audit.sh": contentDigest(new TextEncoder().encode(wrote)) } };
171
+ expect(managedDrift(root, config)).toEqual([]);
172
+
173
+ writeFileSync(join(root, "audit.sh"), "check_one\n");
174
+ const drift = managedDrift(root, config);
175
+ expect(drift).toHaveLength(1);
176
+ expect(drift[0]).toContain("audit.sh");
177
+ expect(drift[0]).toContain("edited after orly wrote it");
178
+ // Deliberately NOT "run orly update": the local content may be the better
179
+ // version, and update discards it. The source is where it has to go.
180
+ expect(drift[0]).toContain("move the change into the pack source");
181
+ });
182
+
183
+ test("a file with no recorded digest makes no claim", async () => {
184
+ // Two cases share this path: a config written before digests existed, and a
185
+ // hook orly manages but did not author. Neither may be reported as drift.
186
+ const root = scratch();
187
+ writeFileSync(join(root, "hook.sh"), "the repository's own\n");
188
+ const config = { ...(await seedConfig(root)), managed: ["hook.sh"], digests: {} };
189
+ expect(managedDrift(root, config)).toEqual([]);
190
+ });
191
+ });
192
+
159
193
  describe("managedDrift", () => {
160
194
  test("every managed file present reports no drift", async () => {
161
195
  const root = scratch();
@@ -223,6 +257,7 @@ describe("writeConfig", () => {
223
257
  commands: { conform: [["make", "audit"]] },
224
258
  surfaces: { user: ["src/"], docs: ["docs/"] },
225
259
  managed: ["AGENTS.md"],
260
+ digests: {},
226
261
  };
227
262
  await writeConfig(root, mine);
228
263
  await writeConfig(root, { ...(await readConfig(root))!, orly_version: "0.4.0", managed: ["AGENTS.md", "dispatch/write_rust.md"] });
package/src/config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
2
3
  import { dirname, extname, join, relative } from "node:path";
3
4
 
@@ -16,6 +17,9 @@ const EXTENSIONS_FIELD = "extensions";
16
17
  const PACKS_FIELD = "packs";
17
18
  const VERSION_FIELD = "orly_version";
18
19
  const MANAGED_FIELD = "managed";
20
+ const DIGESTS_FIELD = "digests";
21
+ const DIGEST_ALGORITHM = "sha256";
22
+ const DIGEST_ENCODING = "hex";
19
23
  const COMMANDS_FIELD = "commands";
20
24
  const CONFORM_COMMAND = "conform";
21
25
  const MAKE_COMMAND = "make";
@@ -65,6 +69,7 @@ export type RepoConfig = {
65
69
  commands: Record<string, string[][]>;
66
70
  surfaces: JsonObject | undefined;
67
71
  managed: string[];
72
+ digests: Record<string, string>;
68
73
  };
69
74
 
70
75
  export function configPath(targetRoot: string): string {
@@ -109,9 +114,26 @@ function parseConfig(value: JsonObject): RepoConfig {
109
114
  commands: readCommands(value[COMMANDS_FIELD]),
110
115
  surfaces: isObject(value.surfaces) ? value.surfaces : undefined,
111
116
  managed: stringArray(value[MANAGED_FIELD] ?? [], `${CONFIG_PATH} ${MANAGED_FIELD}`),
117
+ digests: readDigests(value[DIGESTS_FIELD]),
112
118
  };
113
119
  }
114
120
 
121
+ // Absent in a config written before digests existed, and absent per-file for
122
+ // anything orly did not author (a hook it declined to overwrite). Both cases
123
+ // read as "no claim", which is what keeps an older checkout green until its
124
+ // next update rather than failing it for a record it never had.
125
+ function readDigests(value: unknown): Record<string, string> {
126
+ if (!isObject(value)) return {};
127
+ const digests: Record<string, string> = {};
128
+ for (const [path, digest] of Object.entries(value)) if (isString(digest)) digests[path] = digest;
129
+ return digests;
130
+ }
131
+
132
+ /// The digest orly records for content it wrote, and recomputes to check it.
133
+ export function contentDigest(bytes: Uint8Array): string {
134
+ return createHash(DIGEST_ALGORITHM).update(bytes).digest(DIGEST_ENCODING);
135
+ }
136
+
115
137
  // What orly installed here, and whether the engine has moved since.
116
138
  export function staleVersion(config: RepoConfig, installedVersion: string): string | undefined {
117
139
  if (config.orly_version === installedVersion) return undefined;
@@ -152,9 +174,33 @@ export function managedDrift(targetRoot: string, config: RepoConfig): string[] {
152
174
  ...present
153
175
  .filter((relativePath) => ignored.has(relativePath))
154
176
  .map((relativePath) => `managed file is ignored by git: ${relativePath} — orly wrote it and a clone will not receive it; scope the .gitignore rule that excludes it`),
177
+ ...editedManaged(targetRoot, config, present),
155
178
  ].sort();
156
179
  }
157
180
 
181
+ // A managed file whose CONTENT no longer matches what orly wrote.
182
+ //
183
+ // The half `managedDrift` was missing, and the gap was not theoretical: a
184
+ // consuming repository added two checks straight to a materialised gate script,
185
+ // the pack never learned them, and the next update deleted them — while doctor
186
+ // reported green throughout, because it verified that the file EXISTED and
187
+ // never that it still said what orly put there. A gate whose checks can be
188
+ // removed under a passing verifier is not a gate.
189
+ //
190
+ // The remedy for a hit is deliberately not `orly update`: the local content may
191
+ // be the better version, as it was in that case. Update overwrites it; the
192
+ // message says to move the change into the source instead, which is the only
193
+ // way it reaches every other consumer.
194
+ function editedManaged(targetRoot: string, config: RepoConfig, present: string[]): string[] {
195
+ return present.flatMap((relativePath) => {
196
+ const recorded = config.digests[relativePath];
197
+ if (!recorded) return [];
198
+ const actual = contentDigest(readFileSync(join(targetRoot, relativePath)));
199
+ if (actual === recorded) return [];
200
+ return [`managed file was edited after orly wrote it: ${relativePath} — move the change into the pack source, or \`orly update\` to discard it`];
201
+ });
202
+ }
203
+
158
204
  // Key order is fixed so a rewrite produces a reviewable diff: orly's own two
159
205
  // fields sit at the ends, the repository's three in the middle, untouched.
160
206
  export async function writeConfig(targetRoot: string, config: RepoConfig): Promise<string> {
@@ -168,6 +214,7 @@ export async function writeConfig(targetRoot: string, config: RepoConfig): Promi
168
214
  commands: config.commands,
169
215
  ...(config.surfaces ? { surfaces: config.surfaces } : {}),
170
216
  managed: [...config.managed].sort(),
217
+ digests: Object.fromEntries(Object.entries(config.digests).sort(([a], [b]) => a.localeCompare(b))),
171
218
  };
172
219
  await Bun.write(path, `${JSON.stringify(ordered, undefined, JSON_INDENT)}${NEWLINE}`);
173
220
  return path;
@@ -177,7 +224,7 @@ export async function writeConfig(targetRoot: string, config: RepoConfig): Promi
177
224
  // from what the repository already builds with; an empty set is honest rather
178
225
  // than wrong, and says so in the gate's own failure message.
179
226
  export async function seedConfig(targetRoot: string): Promise<RepoConfig> {
180
- return { schema_version: CONFIG_SCHEMA_VERSION, orly_version: "", packs: [], commands: await sniffCommands(targetRoot), surfaces: undefined, managed: [] };
227
+ return { schema_version: CONFIG_SCHEMA_VERSION, orly_version: "", packs: [], commands: await sniffCommands(targetRoot), surfaces: undefined, managed: [], digests: {} };
181
228
  }
182
229
 
183
230
  // What this repository installed and runs with, read from its own `.oracle/`.
package/src/install.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, renameSy
2
2
  import { dirname, extname, join, relative, resolve } from "node:path";
3
3
 
4
4
  import { UNSCOPED_ENVIRONMENT } from "./git_env";
5
- import { CONFIG_PATH, readConfig, seedConfig, selectPacks, writeConfig } from "./config";
5
+ import { CONFIG_PATH, contentDigest, readConfig, seedConfig, selectPacks, writeConfig } from "./config";
6
6
  import { applyMode, assertWritableInside, hashContent, isBelow, isString, JsonObject, modeLabel, objectArray, objectValue, OrlyError, RulesModel, stringArray } from "./model";
7
7
  import { AGENTS_FILENAME, GENERATED_BANNER, installLoaders, Layout, loaderTargets, ORLY_AGENTS_FILENAME } from "./loaders";
8
8
  import { referenceClosureErrors, renderProfileText } from "./references";
@@ -116,7 +116,16 @@ export async function install(model: RulesModel, options: InstallOptions): Promi
116
116
  skipped.push(...hooks.all.filter((path) => !hooks.written.includes(path)));
117
117
  // One file carries both halves: the repository's own fields pass through
118
118
  // untouched, orly's two record what this run installed and wrote.
119
- await writeConfig(targetRoot, { ...config, orly_version: options.orlyVersion, managed: [...planned.map((file) => file.target), ...hooks.all] });
119
+ // Digests cover `planned` only. Hooks are managed — orly restores a missing
120
+ // one — but it does not always AUTHOR them: it refuses to overwrite a hook it
121
+ // did not write. Recording a digest for a file whose content belongs to the
122
+ // repository would report the repository's own edits as drift.
123
+ await writeConfig(targetRoot, {
124
+ ...config,
125
+ orly_version: options.orlyVersion,
126
+ managed: [...planned.map((file) => file.target), ...hooks.all],
127
+ digests: Object.fromEntries(planned.map((file) => [file.target, contentDigest(file.content)])),
128
+ });
120
129
  if (!existing) written.push(CONFIG_PATH);
121
130
  return { ok: true, packs, written: written.sort(), skipped: skipped.sort(), errors: [] };
122
131
  }
@@ -370,6 +379,11 @@ function retargetRulesCitations(text: string, orlyFile: string): string {
370
379
  return text
371
380
  .replaceAll(`](${AGENTS_FILENAME})`, `](${orlyFile})`)
372
381
  .replaceAll(`](../${AGENTS_FILENAME})`, `](../${orlyFile})`)
382
+ // The relative spelling first: `../AGENTS.md` contains `AGENTS.md`, so the
383
+ // bare rule below would rewrite its tail and leave the `../` stranded.
384
+ // Missing it is what rendered `[`../AGENTS.md`](../AGENTS.orly.md)` into
385
+ // four managed pages — a label naming one file over a link to another.
386
+ .replaceAll(`\`../${AGENTS_FILENAME}\``, `\`../${orlyFile}\``)
373
387
  .replaceAll(`\`${AGENTS_FILENAME}\``, `\`${orlyFile}\``);
374
388
  }
375
389