@nurix/etna 0.19.81 → 0.19.83

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.
Files changed (39) hide show
  1. package/bin/etna.js +99 -7
  2. package/data/catalog/index.json +20 -0
  3. package/data/catalog/rules/changelog.md +25 -0
  4. package/data/catalog/rules/cloudflare.md +180 -0
  5. package/data/catalog/rules/code-eval-batcher.md +67 -0
  6. package/data/catalog/rules/database.md +34 -0
  7. package/data/catalog/rules/frontend.md +40 -0
  8. package/data/catalog/rules/general.md +73 -0
  9. package/data/catalog/rules/kb.md +6 -0
  10. package/data/catalog/rules/nomenclature.md +30 -0
  11. package/data/catalog/rules/pnpm.md +57 -0
  12. package/data/catalog/rules/react-native.md +120 -0
  13. package/data/catalog/rules/rust-desktop.md +109 -0
  14. package/data/catalog/rules/temporal.md +78 -0
  15. package/data/catalog/rules/testing.md +64 -0
  16. package/data/catalog/skills/nustack/SKILL.md +15 -0
  17. package/data/catalog/skills/nustack/references/cli-design.md +86 -0
  18. package/data/catalog/skills/nustack/references/monorepo-master-list.md +52 -0
  19. package/data/catalog/skills/nustack/references/monorepo-setup.md +98 -0
  20. package/data/catalog/skills/nustack/references/publishing.md +132 -0
  21. package/data/harness-index.json +1 -1
  22. package/data/harnesses/code/agents/maintenance.md +0 -1
  23. package/data/harnesses/code/agents/retro.md +2 -2
  24. package/data/harnesses/code/codex/agents/retro.toml +2 -2
  25. package/data/harnesses/code/harness.json +2 -2
  26. package/data/harnesses/code/hooks/compliance.mjs +0 -1
  27. package/data/harnesses/code/hooks/lib/session-log.mjs +10 -10
  28. package/data/harnesses/code/skills/documentation/templates/structure.md +0 -1
  29. package/data/harnesses/delivery/agents/maintenance.md +0 -1
  30. package/data/harnesses/delivery/agents/retro.md +2 -2
  31. package/data/harnesses/delivery/codex/agents/retro.toml +2 -2
  32. package/data/harnesses/delivery/harness.json +2 -2
  33. package/data/harnesses/delivery/skills/documentation/templates/structure.md +0 -1
  34. package/data/harnesses/etna/agents/maintenance.md +0 -1
  35. package/data/harnesses/etna/agents/retro.md +2 -2
  36. package/data/harnesses/etna/codex/agents/retro.toml +2 -2
  37. package/data/harnesses/etna/harness.json +2 -2
  38. package/data/harnesses/etna/skills/documentation/templates/structure.md +0 -1
  39. package/package.json +2 -2
package/bin/etna.js CHANGED
@@ -14,12 +14,13 @@
14
14
  // Harnesses are addressed by their harness/<folder> name; several harnesses may
15
15
  // share one plugin.
16
16
 
17
- import { existsSync } from 'node:fs';
17
+ import { existsSync, mkdirSync, copyFileSync, cpSync, rmSync } from 'node:fs';
18
18
  import { join, dirname, resolve } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { spawnSync } from 'node:child_process';
21
21
  import * as p from '@clack/prompts';
22
22
  import { materializeHarness, readJson as readJsonOrThrow } from '../lib/install/materialize.mjs';
23
+ import { projectSkills } from '../lib/codex/skills.mjs';
23
24
 
24
25
  // WHERE A HARNESS'S FILES LAND is not decided here — `lib/install/materialize.mjs` owns that
25
26
  // mapping, because the Apollo push ships the same tree and the two must never diverge. This
@@ -151,20 +152,82 @@ async function installHarness(harnessName, harnesses) {
151
152
  );
152
153
  }
153
154
 
154
- // Parse argv. `--name=<harness>` / `--name <harness>` selects a harness; -h/--help shows
155
+ // Parse argv. `--name=<harness>` / `--name <harness>` selects a harness; `--rule=<name>` and
156
+ // `--skill=<name>` install one catalog component instead of a whole harness; -h/--help shows
155
157
  // help; any other bare token is the dropped positional form (a breaking change in 0.18.0).
156
158
  function parseArgs(args) {
157
159
  let name = null;
158
160
  let help = false;
159
161
  let positional = null;
162
+ const components = [];
163
+ const takeValue = (a, flag, i) =>
164
+ a.startsWith(`${flag}=`) ? [a.slice(flag.length + 1), i] : [args[i + 1] ?? '', i + 1];
160
165
  for (let i = 0; i < args.length; i++) {
161
166
  const a = args[i];
162
167
  if (a === '-h' || a === '--help') help = true;
163
168
  else if (a.startsWith('--name=')) name = a.slice('--name='.length);
164
169
  else if (a === '--name') name = args[++i] ?? '';
165
- else if (!a.startsWith('-') && positional === null) positional = a;
170
+ else if (a === '--rule' || a.startsWith('--rule=')) {
171
+ const [value, next] = takeValue(a, '--rule', i);
172
+ i = next;
173
+ if (value) components.push({ kind: 'rules', name: value });
174
+ } else if (a === '--skill' || a.startsWith('--skill=')) {
175
+ const [value, next] = takeValue(a, '--skill', i);
176
+ i = next;
177
+ if (value) components.push({ kind: 'skills', name: value });
178
+ } else if (!a.startsWith('-') && positional === null) positional = a;
166
179
  }
167
- return { name, help, positional };
180
+ return { name, help, positional, components };
181
+ }
182
+
183
+ // Install one catalog component into the current directory — a rule as
184
+ // `.claude/rules/<name>.md`, a skill as `.claude/skills/<name>/`.
185
+ //
186
+ // Deliberately does NOT record the name in `.claude/harness.json`'s `owned` manifest. Both
187
+ // prune paths key off that manifest, so a name that was never owned is never prune-eligible
188
+ // and the component rides through every later harness roll untouched. Recording it would
189
+ // invert that: the next roll would see it in the prior `owned`, miss it in the bundle, and
190
+ // delete it. Subscription state lives in etna's roster, never in the consumer repo.
191
+ function installComponent({ kind, name }) {
192
+ const catalogDir = join(dataDir, 'catalog');
193
+ const index = readJson(join(catalogDir, 'index.json')) || { rules: [], skills: [] };
194
+ const available = index[kind] || [];
195
+ const label = kind === 'rules' ? 'rule' : 'skill';
196
+
197
+ if (!available.includes(name)) {
198
+ p.log.error(`No ${label} named "${name}" in the catalog.`);
199
+ if (available.length) p.log.message(`Available ${kind}: ${available.join(', ')}`);
200
+ if (kind === 'skills') {
201
+ p.log.message(
202
+ 'Only skills listed in .github/subscribers/skills.md ship in the catalog — the full\n' +
203
+ 'shared catalog is far too large to bundle. Add a row there to make one installable.',
204
+ );
205
+ }
206
+ return false;
207
+ }
208
+
209
+ const targetDir = process.cwd();
210
+ if (kind === 'rules') {
211
+ const dest = join(targetDir, '.claude', 'rules', `${name}.md`);
212
+ mkdirSync(dirname(dest), { recursive: true });
213
+ copyFileSync(join(catalogDir, 'rules', `${name}.md`), dest);
214
+ p.log.success(`Installed rule \`${name}\` → .claude/rules/${name}.md`);
215
+ } else {
216
+ const dest = join(targetDir, '.claude', 'skills', name);
217
+ rmSync(dest, { recursive: true, force: true });
218
+ mkdirSync(dirname(dest), { recursive: true });
219
+ cpSync(join(catalogDir, 'skills', name), dest, { recursive: true });
220
+ p.log.success(`Installed skill \`${name}\` → .claude/skills/${name}/`);
221
+ // The Codex mirror rebuilds from the INSTALLED tree rather than the bundle, so a
222
+ // subscribed skill is projected for free — the same path a harness install takes.
223
+ try {
224
+ projectSkills({ targetDir, projectHasGit: existsSync(join(targetDir, '.git')) });
225
+ p.log.info(`Mirrored to .agents/skills/${name}/ for Codex.`);
226
+ } catch (e) {
227
+ p.log.warn(`Codex mirror skipped: ${e?.message || e}`);
228
+ }
229
+ }
230
+ return true;
168
231
  }
169
232
 
170
233
  // npx caches aggressively: a bare `npx @nurix/etna` can re-run a STALE cached copy
@@ -200,22 +263,51 @@ function selfUpdateOrReexec() {
200
263
  async function main(args) {
201
264
  const idx = loadHarnessIndex();
202
265
  const harnesses = idx.harnesses || {};
203
- const { name, help, positional } = parseArgs(args);
266
+ const { name, help, positional, components } = parseArgs(args);
204
267
 
205
268
  if (help) {
206
269
  p.intro('@nurix/etna — Nurix harness installer');
207
270
  p.log.message(
208
271
  'Usage:\n' +
209
272
  ' npx @nurix/etna pick a harness from the menu\n' +
210
- ' npx @nurix/etna --name=<harness> install that harness directly',
273
+ ' npx @nurix/etna --name=<harness> install that harness directly\n' +
274
+ ' npx @nurix/etna --rule=<name> install one catalog rule\n' +
275
+ ' npx @nurix/etna --skill=<name> install one subscribed catalog skill',
211
276
  );
212
277
  printHarnessList(harnesses);
213
- p.outro('No args → interactive menu. --name=<harness> → direct install.');
278
+ const index = readJson(join(dataDir, 'catalog', 'index.json'));
279
+ if (index) {
280
+ p.log.message(
281
+ `Catalog rules: ${(index.rules || []).join(', ') || '(none)'}\n` +
282
+ `Catalog skills: ${(index.skills || []).join(', ') || '(none)'}`,
283
+ );
284
+ }
285
+ p.outro('No args → interactive menu. --name / --rule / --skill → direct install.');
214
286
  return;
215
287
  }
216
288
 
217
289
  p.intro('@nurix/etna — Nurix harness installer');
218
290
 
291
+ // A component install is standalone and skips the harness path entirely: it writes one
292
+ // file or folder and records no ownership, so it needs no bundle, no manifest, and no
293
+ // prune pass. Combining it with --name would make the ordering load-bearing (the harness
294
+ // roll can prune a name it owns), so the flags are mutually exclusive by design.
295
+ if (components.length) {
296
+ if (name !== null) {
297
+ p.log.error('--name installs a whole harness; --rule/--skill install one component.');
298
+ p.outro('Run them as separate commands — a harness roll can prune what it owns.');
299
+ process.exit(1);
300
+ }
301
+ const failed = components.filter((component) => !installComponent(component));
302
+ p.outro(
303
+ failed.length
304
+ ? `${components.length - failed.length}/${components.length} installed — ${failed.length} not in the catalog.`
305
+ : `Installed ${components.length} component(s) into .claude/.`,
306
+ );
307
+ if (failed.length) process.exit(1);
308
+ return;
309
+ }
310
+
219
311
  // Breaking change (0.18.0): the positional form `etna <harness>` was replaced by
220
312
  // `--name=<harness>`. Fail loudly with the new syntax rather than silently misfiring.
221
313
  if (positional && name === null) {
@@ -0,0 +1,20 @@
1
+ {
2
+ "rules": [
3
+ "changelog",
4
+ "cloudflare",
5
+ "code-eval-batcher",
6
+ "database",
7
+ "frontend",
8
+ "general",
9
+ "kb",
10
+ "nomenclature",
11
+ "pnpm",
12
+ "react-native",
13
+ "rust-desktop",
14
+ "temporal",
15
+ "testing"
16
+ ],
17
+ "skills": [
18
+ "nustack"
19
+ ]
20
+ }
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ Every unit of work leaves one record — `docs/history/changelog/YYYY-MM-DD-{three-words}.md` (the three words name the work, not the commit) — plus its row in `docs/history/CHANGELOG.md`, appended in the same pass so the index never trails the folder. Entry format, field rules, and the closed exemption list are the `documentation` skill's `templates/changelog-entry.md`; this rule owns when an entry is owed and how it lives. An entry is never a commit summary — it carries the refinement context only the working session holds, which no diff reconstructs.
4
+
5
+ ## When an entry is owed
6
+
7
+ | Trigger | What to write |
8
+ | --- | --- |
9
+ | A plan reaches `Status: Complete` | A draft entry for that plan, linked to it — written now, while the session still holds the context |
10
+ | The user asks for a changelog | The entry for the work in hand. No commit required |
11
+ | A commit is made | Every non-exempt commit is covered by an entry — the `commit` skill writes or completes it (no `commit` skill in the runtime ⇒ write the entry from the `documentation` skill's `templates/changelog-entry.md` and stage it with the commit) |
12
+
13
+ Route the lesson by who pays for losing it: product context — what changed and why — belongs in the entry; anything the **next session would re-pay** — a library's API drifted from what the model expects, a validator's quirk, a tooling workaround — goes to `docs/memory/main.md` as well, because a changelog is read by people tracing history and memory is read by every session before it repeats the mistake.
14
+
15
+ ## Lifecycle
16
+
17
+ Draft (created before its commit exists) → complete (missing fields filled from the diff and the session) → frozen the moment it rides in a commit. **Immutability begins at commit, not at creation** — a draft is meant to be edited, which is what lets a completed plan record its context before anything is committed; a committed entry is history, and a correction is a new entry linking back, **never** an edit.
18
+
19
+ ## Granularity
20
+
21
+ A commit is the smallest unit an entry may describe; above that the mapping is judgment — four commits may be four entries or one, whichever reads better as history. An entry spanning several commits rides in the **last** of them, naming the predecessors in `Commits:`. When work exceeds what a draft describes, write a second entry rather than stretching the draft — the first stays an honest account of the plan.
22
+
23
+ ## Migrations are a different genre
24
+
25
+ **One-shot migrations are a different genre.** Work that runs **once** against a live system — a data backfill, a schema cutover, a dependency or harness upgrade rolled across repos — is a **migration runbook** at `docs/history/migrations/YYYY-MM-DD-{slug}.md` (template: the `documentation` skill's `templates/migration-runbook.md`), authored **before** execution and outcome-stamped after. A changelog entry says what changed in the repo; a runbook says what was executed against the world and how to verify or undo it. A repeatable procedure is neither — that is `docs/operations/`. When a runbook defers targets, it records **why** and the **trigger to re-evaluate them**, so the next run re-checks rather than inheriting the skip.
@@ -0,0 +1,180 @@
1
+ # Cloudflare Worker
2
+
3
+ Conventions for Cloudflare Workers — `wrangler.jsonc` setup, vars and secrets, and code shape (dispatch, errors, modules, WebSockets). Schema design doctrine is the `schema-design` skill.
4
+
5
+ ## 1. Project & wrangler.jsonc Setup
6
+
7
+ ### 1.1 Wrangler version
8
+
9
+ - Use Wrangler **v4**. If an editor flags valid config as invalid, check the wrangler version first.
10
+ - **No `package.json`** (dep-free worker): use `npx wrangler@4` in every command and doc. Never create a `package.json` just to record the version.
11
+ - **`package.json` exists**: pin wrangler to the exact version tested against (not `^4.0.0`), reinstall, and verify with `wrangler deploy --dry-run` before pushing.
12
+
13
+ ### 1.2 Config format
14
+
15
+ - Use `wrangler.jsonc` (not `wrangler.toml`).
16
+ - Include `"$schema": "node_modules/wrangler/config-schema.json"`.
17
+
18
+ ### 1.3 Comments in jsonc
19
+
20
+ None by default. Keep a comment only when it captures a non-obvious **WHY** — a hidden constraint, workaround, or account-specific quirk. Never narrate what a key already says.
21
+
22
+ ### 1.4 Public entry points — workers.dev & preview URLs
23
+
24
+ - **Don't expose on `workers.dev` by default** — it is public and guessable (`<worker>.<account>.workers.dev`). Default to `"workers_dev": false`; when a deploy is requested and no custom domain or route exists yet, warn the user that enabling it makes the Worker public and enable it only on confirmation.
25
+ - **`preview_urls` is a second public door** (`<version>-<worker>.<account>.workers.dev`) — `workers_dev` alone is not the whole switch. Set `"preview_urls": false` with it.
26
+ - Pair a custom domain with a `routes` entry using `custom_domain: true`. **The zone must be in the same account as the Worker** — a route pointing at a zone the account doesn't hold fails at deploy, so check the zone before writing the route.
27
+ - **Declare both keys explicitly, in the same edit that adds the route** — never leave them to the implicit default, and never delete them as redundant once a route exists.
28
+
29
+ > **Adding a route cuts over atomically — you cannot stage it.** The moment a `routes` entry lands, an absent `workers_dev` key disables `workers.dev` on that same deploy; "add the route now, turn it off once the custom domain answers" is not a sequence wrangler will execute. Where the certificate is slow to provision, that gap is downtime — confirm the outage window with the user before cutting over.
30
+
31
+ Deleting a route is likewise a cutover: `workers.dev` does not return until `"workers_dev": true` is set explicitly, and a torn-down hostname `404`s rather than redirecting — callers holding the old URL get no forwarding.
32
+
33
+ ### 1.5 Default config
34
+
35
+ Every Worker carries these keys. Deviate only where a rule below says to.
36
+
37
+ ```jsonc
38
+ "workers_dev": false,
39
+ "preview_urls": false,
40
+ "observability": {
41
+ "logs": {
42
+ "enabled": false,
43
+ "invocation_logs": true
44
+ }
45
+ }
46
+ ```
47
+
48
+ - `workers_dev` / `preview_urls` — entry-point policy and cutover mechanics: §1.4.
49
+ - **Bootstrap override:** a new Worker (especially cron-only) runs `logs.enabled: true` until a few real runs look clean, then flips back — with logs off, `console.log` is live-only via `npx wrangler tail` and nothing persists after a failed run.
50
+
51
+ ### 1.6 Project structure & dependencies
52
+
53
+ - Main entry file: `index.ts`. Reusable helpers: `helper.ts`.
54
+ - TypeScript under `strict: true` — no build step, and a minimal `tsconfig.json` ships even in dep-free Workers.
55
+ - Don't run `npm run build` or `npm run deploy` in examples.
56
+ - **`package.json` / `package-lock.json` are opt-in.** Create them only when code under `src/` imports an npm package at runtime; add both the moment the first real dep lands. Run wrangler, linters, and one-offs via `npx <tool>`. `node_modules/` stays in `.gitignore` either way.
57
+
58
+ ### 1.7 Environment & configuration
59
+
60
+ - Use the deployed Cloudflare URL in docs/tests, not `localhost`.
61
+ - Prefer SQLite-based Durable Objects over in-memory-only designs; add classes under `migrations.new_sqlite_classes`.
62
+
63
+ ### 1.8 Cloudflare Secrets Store
64
+
65
+ Use when the user mentions it. Use the same `binding` name as the `secret_name`.
66
+
67
+ ```jsonc
68
+ "secrets_store_secrets": [
69
+ { "binding": "CLICKUP_KEY", "store_id": "792206780c0c46e8ba8ade3f21b44eac", "secret_name": "CLICKUP_KEY" }
70
+ ]
71
+ ```
72
+
73
+ Access in code: `await env.CLICKUP_KEY.get()`.
74
+
75
+ ### 1.9 Naming
76
+
77
+ - Repo/dir: `cf-<name>` (e.g. `cf-extract-design`).
78
+ - Worker name: `<name>` — repo name minus the `cf-` prefix.
79
+ - R2 bucket, D1 database, and KV namespace names: equal to the worker name.
80
+
81
+ ### 1.10 Vars & secrets
82
+
83
+ **Non-secret vars live in the `vars` block in `wrangler.jsonc`**, checked in. Never move them to the dashboard. A single-tenant application Worker declares them at the top level; a multi-tenant Worker declares per-tenant vars under each `env.<tenant>` block (§11).
84
+
85
+ **Secrets never touch git.** Local dev: a gitignored `.dev.vars` (`KEY=VALUE` dotenv, auto-loaded by `wrangler dev`) with a committed `.dev.vars.example` listing the keys and placeholder values. Production: `wrangler secret put <NAME>`, a gitignored `.secrets` file pushed with `wrangler secret bulk .secrets` (or shipped with a deploy via `wrangler deploy --secrets-file .secrets`), or the Secrets Store (§1.8). Never log or echo a secret value.
86
+
87
+ **No `keep_vars`** — leave it unset. It guards only plaintext vars set out-of-band in the dashboard and never touches secrets; `wrangler secret put` secrets survive every deploy regardless, so never add it to protect them. Only the multi-tenant pattern sets `keep_vars: true` (§11).
88
+
89
+ **Never name a deployed-value file `.env` or `.env.<anything>`** — `wrangler dev` auto-loads a root `.env` as local dev vars, so a prod-valued `.env` silently becomes the local environment on a fresh clone or in CI. Committed `*.example` files carry keys with placeholder values only.
90
+
91
+ `.gitignore`: `.dev.vars`, `.secrets`, `.secrets.*`, `.env`, `.env.*`
92
+
93
+ ### 1.11 Deploys
94
+
95
+ **Never auto-deploy.** `wrangler deploy` (any variant — `--env`, `versions upload`) runs only when the user explicitly asks. Building, dry-running (`wrangler deploy --dry-run`), and `wrangler dev` are fine unprompted. Finish the change, then tell the user it's ready to deploy.
96
+
97
+ ## 2. Code & Helpers
98
+
99
+ - Use `generateUuidV7()` in `helper.ts` for IDs.
100
+ - Only `await` what's needed (e.g. `request.json()`, `do.fetch(...)`).
101
+
102
+ ## 3. fetch() Structure
103
+
104
+ `validateRequest()` first, then `switch (path)` for dispatch. Single `try/catch` wrapping the entire handler. No if-chains, no regex in fetch.
105
+
106
+ ```js
107
+ export default {
108
+ async fetch(request, env, ctx) {
109
+ try {
110
+ const route = validateRequest(request);
111
+ switch (route.path) {
112
+ case "monitors":
113
+ return await handleMonitors({ ...route, env, ctx });
114
+ default:
115
+ throw { code: 404, message: "Route not found" };
116
+ }
117
+ } catch (error) {
118
+ console.error("Unhandled error in fetch()", error);
119
+ return createErrorResponse(
120
+ error.message || "Internal server error",
121
+ error.code || 500,
122
+ );
123
+ }
124
+ },
125
+ };
126
+ ```
127
+
128
+ No nested `try/catch` except around WebSocket `send`/`close` (see §9).
129
+
130
+ ## 4. validateRequest()
131
+
132
+ Lives in `helper.ts`. Parses the URL in one place and returns `{ path, method, query, resourceId, subPath }`; throws `{ code: 400 }` when a path requires a resource ID and none is present.
133
+
134
+ ## 5. Validation
135
+
136
+ Fail fast with thrown `{ code, message }` (`400` bad input, `401` missing/invalid auth). Validate required fields explicitly — no optional chaining for required fields.
137
+
138
+ ## 6. Errors & Responses
139
+
140
+ - Throw structured errors: `{ code, message }`. Always `console.error()` with context (path, user, etc.). No empty catch blocks.
141
+ - Response envelope `{ status: 1|0, message, data }` — the shared cross-service contract.
142
+ - `createResponse(data, message, code)` in `helper.ts` bakes CORS in, returns a null body for 204/304, and uses proper HTTP codes; `createErrorResponse(message, code)` delegates to it.
143
+ - CORS is passive: a `CORS_HEADERS` const in `helper.ts`, spread into `createResponse()` — no explicit OPTIONS handler, no wrapper functions. Origin-restricted CORS builds the headers from an allowlist (plus `localhost` in dev), still inside `createResponse()`.
144
+
145
+ ## 7. Handler Signatures
146
+
147
+ Single destructured object — never positional args. Compose by spreading route with env/ctx: `handleGetStats({ request, env, resourceId })`, not `handleGetStats(request, id, env)`.
148
+
149
+ ## 8. Module Structure
150
+
151
+ Feature-based folders with `handle.ts` + `helper.ts` pairs. `index.ts` only does dispatch.
152
+
153
+ | Path | Role |
154
+ | --- | --- |
155
+ | `src/index.ts` | Entry point — dispatch only |
156
+ | `src/helper.ts` | Shared CORS, responses, validation, utilities |
157
+ | `src/database.ts` | Shared D1 queries |
158
+ | `src/<feature>/handle.ts` | Route handlers |
159
+ | `src/<feature>/helper.ts` | Feature-specific helpers & DB ops |
160
+
161
+ ## 9. WebSockets
162
+
163
+ Standard `WebSocketPair` handshake (`server.accept()`, return 101 with `webSocket: client`). Check `readyState === WebSocket.OPEN` before sending. The only acceptable nested `try/catch`: wrap `send`; in its catch, `close(1011)` inside a bare inner try, then evict the session.
164
+
165
+ ## 10. D1 Schema
166
+
167
+ Follows the shared database naming and column-type vocabulary; what earns a table and the rest of the design doctrine is the `schema-design` skill.
168
+
169
+ ## 11. Multi-tenant Workers (native environments)
170
+
171
+ Rare — only when **one Worker codebase ships as several independent tenants**: separate Workers, domains, and data stores from a single `src/`. Use **native wrangler environments**, never one Worker that switches tenant at runtime. For a single-tenant application Worker, §1.10 applies and this section does not.
172
+
173
+ - **One config, `env.<tenant>` blocks.** The top-level block is **local dev** (`wrangler dev`): a `*-dev` name and placeholder D1/KV ids, simulated locally. Each `env.<tenant>` block is **one production tenant**; deploy it with `wrangler deploy --env <tenant>`.
174
+ - **Named environments never inherit** `vars` / `d1_databases` / `kv_namespaces` / `routes` / `secrets_store_secrets` / `assets` from the top level — **each `env.<tenant>` declares them in full**. Dropping a binding from an env block disables that capability for that one tenant.
175
+ - **Per-tenant non-secret vars live in `env.<tenant>.vars`**, checked in.
176
+ - **Local-dev vars** come from a gitignored `.vars.local`, injected as `--var` by the dev script (the top-level block carries no in-config `vars`).
177
+ - **Per-tenant secrets, never in git** — `wrangler secret put <NAME> --env <tenant>`, or a gitignored per-tenant `.secrets` (e.g. `worker/tenants/<tenant>/.secrets`) pushed with `wrangler secret bulk --env <tenant>`, plus a committed `.secrets.example`. Secrets Store bindings are declared inside each env block.
178
+ - **`keep_vars: true`** (top level) keeps a per-tenant deploy **additive**: tenants inject their non-secret vars via `--var` (or the dashboard), and without it a deploy would wipe them. On secrets it changes nothing (§1.10).
179
+
180
+ `.gitignore`: §1.10's list, with `.vars.local` in place of `.dev.vars`.
@@ -0,0 +1,67 @@
1
+ # Code-Eval Batcher
2
+
3
+ Evaluating and ranking **multiple code projects** — candidate take-homes, submissions, a folder of repos — against the production-readiness + product-quality rubric. The main agent performs this directly rather than delegating it: the rollup needs every scorecard in one context, so the session that ranks must be the one that fans out. A single project does **not** need this rule — invoke the `evaluator` skill directly. Scoring how a person *steered a session* is `interview-eval`, not this rule.
4
+
5
+ ## Division of labor
6
+
7
+ Per-project scoring — the rubric, gates, absolute banding, the 8 sub-criteria, every recipe — belongs to the **`evaluator` skill** (`.claude/skills/evaluator/`); never re-derive it here. This rule owns only what the skill deliberately doesn't: **enumeration, parallel dispatch, weighting, and the cross-project rollup.** **Never re-band a project against the others** — cohort-relative banding is the anti-pattern this design removed; the only cross-project levers live in the weights block.
8
+
9
+ ## Procedure
10
+
11
+ | # | Step |
12
+ | --- | --- |
13
+ | 0 | **Preflight once:** `bash .claude/skills/evaluator/preflight.sh`. Halt on non-zero and report the missing tools — never score with degraded signals. |
14
+ | 1 | **Enumerate** the projects → local paths, one slug each (its dir name). Choose an `out_dir` (default `./code-eval-out`). |
15
+ | 2 | **Dispatch in parallel — batches of 4.** Per project: an `Agent` call with `subagent_type="general-purpose"` instructing it to invoke the `evaluator` skill on that one path, write the workspace to `<out_dir>/<slug>/`, validate it (`python3 .claude/skills/evaluator/validate_scorecard.py <out_dir>/<slug>/scorecard.yaml`), and return the workspace path + the scorecard's `normalized`. **Issue all four calls in one block** — that is what makes them concurrent; start the next batch when the block returns. |
16
+ | 3 | **Collect** every `<out_dir>/*/scorecard.yaml`. Re-dispatch a validation failure once; on the second failure mark the project `VALIDATION_FAILED` and continue. |
17
+ | 4 | **Weight + normalize** every project (math below) using the weights block. |
18
+ | 5 | **Rollup** → `<out_dir>/results/{scorecard.csv, ranking.md, summary.md}` (contents below), sorted by rank score desc. Leave everything uncommitted. |
19
+
20
+ ## Weighting math
21
+
22
+ For each project `p`, read each sub-criterion `s`'s raw 0–5 `score` (the evaluator's absolute band) from its `scorecard.yaml`:
23
+
24
+ 1. **Weighted (0–100)** over sub-criteria with a non-null `score` and `weight > 0`:
25
+ `weighted_p = 100 × Σ_s (score_{p,s} / 5 × weight_s) / Σ_s weight_s`
26
+ 2. **Gate penalty:** `adjusted_p = max(0, weighted_p − Σ_{g ∈ gates_triggered_p} gate_penalties[g])`.
27
+ 3. **Normalize** across the set `{adjusted_p}` per `normalization`:
28
+ - `percent_of_max` (default) → `adjusted_p` unchanged — absolute, comparable across runs.
29
+ - `min_max` → `100 × (adjusted_p − min) / (max − min)`; `max == min` ⇒ all 100. Spreads a tight set; not comparable across runs.
30
+ - `z_score` → `clamp(0, 100, 50 + 10 × (adjusted_p − mean) / sd)`; `sd == 0` or n < 2 ⇒ fall back to `adjusted_p`.
31
+
32
+ `VALIDATION_FAILED` projects are excluded from min/max/mean/sd and listed separately. A null sub-criterion drops out of that project's weighted average — never impute a 0. Compute with a short throwaway script over the scorecards; round reported scores to 2 decimals.
33
+
34
+ | Output | Contents |
35
+ | --- | --- |
36
+ | `scorecard.csv` | `rank, project, <8 scores>, gates, short_circuited, evaluator_total, weighted, penalty, rank_score` |
37
+ | `ranking.md` | ranked table headed with the normalization method — the sort key |
38
+ | `summary.md` | n scored, the exact weights / penalties / normalization used, top-5 / bottom-5, gate frequency, any `VALIDATION_FAILED` |
39
+
40
+ ## Weights — edit this block to change the ranking
41
+
42
+ The math above consumes these values verbatim. Edits here are overwritten on a harness re-install — to persist a custom weighting, drop a `code-eval-batcher.weights.json` with the same shape in the `out_dir` and read that instead.
43
+
44
+ ```json
45
+ {
46
+ "weights": {
47
+ "code_quality": 12.5,
48
+ "testing": 12.5,
49
+ "deployment_readiness": 12.5,
50
+ "maintainability": 12.5,
51
+ "problem_substantiveness": 12.5,
52
+ "ux_mechanics": 12.5,
53
+ "value_articulation": 12.5,
54
+ "mechanical_ambition": 12.5
55
+ },
56
+ "gate_penalties": {
57
+ "DEMO": 0, "BROKEN": 0, "UNTESTED": 0, "TESTS_TRIVIAL": 0,
58
+ "SECRETS": 0, "VULN": 0, "AUTH": 0, "OBSERVABILITY": 0
59
+ },
60
+ "normalization": "percent_of_max"
61
+ }
62
+ ```
63
+
64
+ - **`weights`** — relative importance; any non-negative numbers, normalized to sum 100 over the present, weighted sub-criteria. `0` drops a sub-criterion. The all-12.5 default reproduces the evaluator's own equal-weight total.
65
+ - **`gate_penalties`** — flat 0–100-scale points docked per triggered gate, on top of the band caps the evaluator already applied. Default 0 = rely on the evaluator's caps alone. E.g. `SECRETS: 15` docks 15 from any project with a committed secret.
66
+
67
+ **No silent fallback.** A malformed weights block, a failed preflight, or a scorecard that won't validate is reported loudly — never worked around.
@@ -0,0 +1,34 @@
1
+ # Database
2
+
3
+ Naming and the fixed column vocabulary for relational schemas in **every** Nurix project — one concept, one name, everywhere. Written for SQLite / Cloudflare D1 + Drizzle; a Postgres project keeps the same column names and semantics with native types (`uuid`, `timestamptz`, `boolean`). **Design doctrine — what earns a table, state vs. logs, junctions, FK policy, uniqueness traps — is the `schema-design` skill; consult it before any schema change.**
4
+
5
+ ## Keys & the shared vocabulary
6
+
7
+ Three column roles are fixed across every table and every project:
8
+
9
+ - **`id`** — the primary key, **always a UUID** (`TEXT PRIMARY KEY` on D1, `uuid` on Postgres), never a synthetic or natural key (❌ `type_page_button`, `human:default`). Runtime rows mint a UUIDv7; seed rows derive a deterministic UUIDv5 of the natural key so re-seeds stay stable. **Never** mix synthetic and UUID ids in one table. The single exemption is a log whose **composite key is its ordering** (`PRIMARY KEY (<entity>_id, sequence)`), declared at the table that uses it — everything else, logs included, takes a UUIDv7.
10
+ - **`slug`** — the **stable key rows in another scope reference**: `UNIQUE`, lowercase `snake_case`. A slug means the same thing in every scope, so a slug reference survives a copy with no remapping — copying between scopes is the `schema-design` skill. ❌ Never rename this role `handle`, `key`, `code`, or `name` in another project.
11
+ - **`label`** — the **human display name** — ❌ never `name`, `title`, or `display_name`.
12
+
13
+ Reference columns name what they point at: `<entity>_id` for a UUID-PK reference (`user_id`, `app_id`), `<entity>_slug` for a slug reference (`source_slug`).
14
+
15
+ - **Enforced, same scope** (the referenced row must exist) → a real `FOREIGN KEY (<x>_id) REFERENCES <table>(id)`.
16
+ - **Cross-scope** (the reference must survive being copied) → `<x>_slug` as a plain column, **no** FK — an FK would bind the row to one scope's copy of the target, which is the remapping the slug exists to avoid.
17
+ - **Drift-tolerant** (a controlled-vocabulary value that may fall off-list) → a plain value column, **no** FK, paired with an `is_*` boolean recording whether the value is in the canonical set.
18
+
19
+ **Every inbound FK to a hub table declares a deliberate `ON DELETE`.** A hub (`users`, `devices`, anything several modules point at) collects edges from teams that never meet, and a single `NO ACTION` among them turns deleting the parent into a constraint error naming neither table. Decide per edge: the child dies with the row (`CASCADE`) or survives it (`SET NULL`, which needs a nullable column) — `NO ACTION` on a hub is a deferred outage, not a default.
20
+
21
+ ## Naming
22
+
23
+ - **Tables** — `snake_case`, plural noun for what they hold (`users`, `apps`, `devices`).
24
+ - **Junction (M:N) tables** — `<entityA>_<entityB>_map`, **always**, even when the join carries its own columns; singular stems, owning/anchor entity first (`app_user_map`, `app_device_map`). ❌ Never a "nice" noun (`members`, `installs`, `memberships`, `enrolments`) — the name must read as a join, and a reviewer should know both sides from it. The `_map` suffix is reserved for genuine M:N — the cardinality test that decides junction vs. column is the `schema-design` skill.
25
+ - **Columns** — `snake_case`; booleans `is_*` / `has_*`; timestamps `created_at` / `updated_at`; foreign keys `<entity>_id`.
26
+ - **Indexes** — `<table>_<col(s)>_idx`; unique `<table>_<col(s)>_unique`; a junction's composite unique reads `<table>_<colA>_<colB>_unique` (`app_user_map_app_user_unique`).
27
+ - **Drizzle mirror** — the exported object is the camelCase table name (`app_user_map` → `appUserMap`); column props are the camelCase of the column (`created_at` → `createdAt`). The `sqliteTable("…")` string and every index name must match the DDL (`init.sql`) exactly — the schema-parity boundary.
28
+
29
+ ## Column types (SQLite / D1)
30
+
31
+ - **Timestamps** — `created_at` + `updated_at`, both `TEXT NOT NULL DEFAULT (datetime('now'))`, on **every** table — except append-only logs, which carry only `created_at`.
32
+ - **Booleans** — `INTEGER` (Drizzle `integer(…, { mode: "boolean" })`); the `is_*` / `has_*` names are § Naming.
33
+ - **JSON** — `TEXT` (Drizzle `text(…, { mode: "json" })`).
34
+
@@ -0,0 +1,40 @@
1
+ # Frontend
2
+
3
+ Tailwind v4 + shadcn-based UI conventions for **web** apps — styling, the `@nurix/components` library, the `design.md` token artifact, and design-skill routing — plus the TypeScript conventions every package compiles under.
4
+
5
+ - **Build every UI through the `design` skill** — never hand-roll layouts; the page archetypes, the `design.md` identity system with its token artifact (`design.tokens.css`), and chrome wiring live in the skill.
6
+ - **The brief's stated theme binds** — dark boots dark when the brief says so (recorded in the project's `design.md`); light is the default only when the brief is silent.
7
+ - **`@nurix/components`** is the UI library — import by subpath (`@nurix/components/button`). **Never** `npx shadcn add`, no `components.json` scaffold — consume the prebuilt library (`.tsx`, not `.jsx`).
8
+ - Vite: `@tailwindcss/vite` + `@vitejs/plugin-react-swc`. Pin `react` / `react-dom` / `react/jsx-runtime` / `react/jsx-dev-runtime` to the app's single copy via `resolve.alias` — `@nurix/*` ship nested react, and two copies means two hook dispatchers.
9
+ - Tailwind v4 is CSS-only — no `tailwind.config.js`, no PostCSS.
10
+ - CSS entry `globals.css`, imports in order: `@import "./fonts.css"` (`@fontsource` registrations) → the project's `design.tokens.css` (derived from the repo-base `design.md` by the design skill's validator — **never** hand-edited) → `@import "tailwindcss"` → the library's `styles.css` — then `@theme inline {…}` mapping vars to tokens.
11
+ - Tokens: CSS vars in `:root, :root[data-theme="light"]` and `:root[data-theme="dark"]` (both specificity 0,2,0 — armor a later bare `:root` can't defeat), consumed as semantic Tailwind tokens (`bg-card`, `text-foreground`). `globals.css` carries the active `@custom-variant dark` line; the theme attribute is `data-theme`. The library bundles `class-variance-authority`, `clsx`, `tailwind-merge` — don't re-add them.
12
+ - Spacing in 2/4/6/8; `object-cover`; `truncate`; color through tokens — **never** hardcoded hex in components.
13
+
14
+ ## Component Search Order
15
+
16
+ **A component already exists until the search proves otherwise** — building one is the last step, never the first instinct, and a primitive the library ships is never re-implemented. Search in order: **`@nurix/components`** (its `exports` keys are the available subpaths) → **the app's own `src/components/`**, which in a monorepo is the consuming package's pool → **the route folders**, where a near-match is a misfiled generic to promote rather than duplicate.
17
+
18
+ **A new component lands in `src/components/` on first use, not on second.** Waiting for reuse to promote it fails in practice: nobody notices the second use, so the promotion never happens and the component sits where the next session will not look. Findability is the point of the pool, so one consumer earns a place in it.
19
+
20
+ **The exception is mechanical — a component importing from a specific route folder belongs to that route folder.** That import is what domain-coupled means, and it is decidable the moment the file is written, where consumer count cannot classify anything until a second use exists. A pool component that starts importing a route has drifted and moves down.
21
+
22
+ **Pay the pollution as you go, never as scheduled cleanup:** re-read the pool before adding — something differing from an existing component by one prop is a prop, not a component — and group the pool by kind once it passes roughly fifteen flat files.
23
+
24
+ ## TypeScript
25
+
26
+ Every package compiles under `strict: true` and `noUncheckedIndexedAccess: true`.
27
+
28
+ ### Type Safety
29
+
30
+ - **Never** `any` to silence the checker. Unknown value → type `unknown`, then narrow (type guard / `zod` parse).
31
+ - **Never** weaken strictness: no `// @ts-nocheck`; `// @ts-expect-error` only with a same-line reason.
32
+ - Infer locals and return types; annotate at boundaries (exports, props, contracts).
33
+
34
+ ### Generics
35
+
36
+ Shared libraries (`@nurix/components`, etc.) are generic on purpose — that's what types `cell(row)`, checks `slug` against `keyof TData`, and ties `data`/`columns` together; **never** widen to `any` to dodge it. State the row type **once per file** and let inference carry it — repeating `fn<Type>(…)` / `<Comp<Type>>` per call site is cosmetic (same safety, same emitted JS):
37
+
38
+ - **Bind a repeated factory once** (preferred): `const col = defineColumn<Row>;` → `col({...})`.
39
+ - **Infer JSX type args from props:** `<Table data={rows} columns={columns} />`, not `<Table<Row> …>`.
40
+ - **Anchor with a collection annotation** when nothing else can: `const columns: ColumnDefinition<Row>[] = [...]`.
@@ -0,0 +1,73 @@
1
+ # General Standards
2
+
3
+ Runtime versions, package policy, source layout, the API response envelope, and GitHub Actions pins — the workspace baseline.
4
+
5
+ ## Source Layout
6
+
7
+ How a package's `src/` is laid out. A single-runtime package drops `server/` and `shared/`.
8
+
9
+ | Folder | Intent — what it protects | Belongs here when |
10
+ | --- | --- | --- |
11
+ | `app/` | The frame that survives navigation — one router, one auth gate, one shell. The property is **instance count, not importance**; without it every route reinvents the chrome, and the shell becomes where "general" code hides. | Mounted once, present on every page |
12
+ | `surfaces/` | An address maps to one deletable folder holding its page, panels, client and private `lib/`. **One folder per route outranks file count** — a one-file route keeps its folder, because the value is that the folder list *is* the route list. | A routable destination |
13
+ | `components/` | UI shared across the app, **and the known place anyone looks before building** — the second job is the load-bearing one, because a component nobody finds gets rebuilt and the app grows three of it that disagree. | A component importing no route folder |
14
+ | `lib/` | The client's non-visual work — API client, session state, ids, timestamps, hooks. | Non-React logic only the client uses |
15
+ | `shared/` | The contracts and domain logic both runtimes read, so the halves cannot drift; without it the contract is stated twice and diverges on the first change nobody propagates. | Client **and** server both import it |
16
+ | `server/` | The other runtime as a peer in the same package — what lets a contract be a file both sides import instead of a shape both sides guess. | Runs on the server runtime |
17
+
18
+ **Split by role, not by feature** — a flat `src/leads/ src/accounts/…` trades one level of depth for unbounded root breadth, and the top-level scan is what keeps a repo legible. The container **bounds** rather than classifies, so `routes/` is an equally valid name for `surfaces/` — use the word the codebase already speaks.
19
+
20
+ **Never `core/`** — its membership test collapses to "is it important?", making it the queue for files nobody could place; a bare `lib/` holding everything non-visual fails the same way, which is what `shared/` prevents. A route's own nested `lib/` is private and is **never** hoisted, and **dependencies point down** — a shared folder importing one route folder is a defect.
21
+
22
+ **A folder earns its place at ~4+ files, or by being a boundary somebody could delete whole.** Below that, inline it: a folder around one file is a longer filename, and a folder holding only another folder is cascade with nothing at it. Density — not depth, not root breadth — is what makes a tree read as sprawling. The boundary clause splits the one-file cases: a one-file route folder stays, a one-file component folder goes.
23
+
24
+ **Never judge dead code by a name grep** — a relative import never contains its own parent's path, so `grep "x/brand"` cannot see `from "./brand/Detail"`, and every folder imported only by its parent reads as dead. Test by transitive reachability from the real entry points, resolving specifiers as the bundler does (`.ts`, `.tsx`, `.css`, `/index.ts`; strip `?raw`/`?url`; map a `.js` specifier onto its `.ts` source), running the test tree as a **second, separate** entry set — reachable from tests alone means a suite pinning code nothing ships, and both go together. Ambient `.d.ts` files enter through tsconfig `include`, so a sweep always reports them dead. A retired mechanism spans more than source — grep the build config for the same concept before calling a removal complete. **Unreachable ≠ unwanted**: a routed surface rendering dummy data is reachable and still not product, and retiring it removes a route, which is a product decision.
25
+
26
+ **Reshape an existing tree largest-lever-first**, verifying typecheck plus the full suite between steps: purge unreachable files → collapse single-occupancy and pass-through folders → push domain-coupled components down → name the shared layer (the only step that adds a top-level name) → optionally add a path alias, which makes depth invisible at the import site and dissolves most "flatten it" instincts. **Delete and promote before you move** — a folder whose name misdescribes its contents just relocates the lie.
27
+
28
+ ## Runtime Versions
29
+
30
+ - **Node.js:** the version pinned in `.nvmrc` / `package.json` `engines` — never a hardcoded number anywhere.
31
+ - **Package manager:** detect from the lockfile — **never** hardcode one.
32
+ - **PostgreSQL:** 17 · **Redis:** 7.x (latest)
33
+
34
+ ## Packages
35
+
36
+ **The approved table is `.claude/hooks/approved-packages.md`**, enforced by the PreToolUse install gate. It records three states, and the gate treats them differently:
37
+
38
+ | The package is… | What happens |
39
+ | --- | --- |
40
+ | in the **Package** column | installs |
41
+ | in a **Don't use** column | **blocked**, and the block names that row's replacement — `moment` answers "use `date-fns`" |
42
+ | in neither | **installs.** No decision exists, so there is nothing to enforce |
43
+
44
+ A rejection is a decision and binds; an absence is not one and never blocks work. Unlisted packages are harvested across the fleet by the package census, and become rows — or bans — by explicit user decision. **Never edit the table to unblock yourself**; a rejection changes only on the user's say-so.
45
+
46
+ Two **dev-only** installs are exempt even from the rejection check being reached: a scoped companion of an approved package (`@testing-library/react` is on the table, so `-D @testing-library/user-event` needs no row of its own) and `@types/*`.
47
+
48
+ ## API Response Envelope
49
+
50
+ Success and error share **one** envelope — a cross-service contract clients branch on, so a per-service "improvement" (`success: true`, a boolean `status`, a bare payload) breaks every consumer:
51
+
52
+ ```json
53
+ {
54
+ "status": 1,
55
+ "message": "Message sent successfully",
56
+ "data": { "delivered": [{ "user@example.com": 2 }] }
57
+ }
58
+ ```
59
+
60
+ Error: `{ "status": 0, "message": "Error description", "data": null }`
61
+
62
+ HTTP codes, default list: `200`, `201` (created), `400`, `401` (not authenticated), `403` (not authorized), `404`, `500` — plus `204`/`304` (null-body responses), `409` (conflicting write), `429` (rate limit), `503` (unhealthy dependency) where the semantics call for them. Middleware- and protocol-emitted codes are exempt from the envelope.
63
+
64
+ ## GitHub Actions
65
+
66
+ Pin the major, and check it against the action's releases — a major behind still runs, so a stale pin never fails, it only warns.
67
+
68
+ | Action | Version | Use |
69
+ | --- | --- | --- |
70
+ | `actions/checkout` | v7 | Repo checkout |
71
+ | `actions/setup-node` | v7 | Node toolchain + npm cache |
72
+
73
+ Set `node-version` via `node-version-file: .nvmrc`, **never** the runner's default.
@@ -0,0 +1,6 @@
1
+ # Knowledge-Base Folders
2
+
3
+ Conventions for any folder that is majority markdown — `docs/`, `research/`, an Obsidian vault, any knowledge base. Folder shape and linking only. **Instruction files are not a knowledge base and this rule does not reach them** — `rules/`, `agents/`, and skill bodies carry their own linking discipline and no `Readme.md`.
4
+
5
+ - **Every topic folder carries a `Readme.md`**, created with the topic's first document — **never** `index.md` or an unscoped filename. `Readme.md` is what GitHub and Obsidian surface first, so every folder introduces itself.
6
+ - **Every mention is a link** — wikilinks (`[[other-doc]]`) inside Obsidian-native vaults, relative markdown links (`[label](../other-doc.md)`) everywhere else. Unlinked mentions decay a knowledge graph into a pile of files.