@nurix/etna 0.19.78 → 0.19.80

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,5 +1,5 @@
1
1
  {
2
- "fingerprint": "728af89f1e8e",
2
+ "fingerprint": "f0d6766a263f",
3
3
  "harnesses": {
4
4
  "code": {
5
5
  "plugin": "code",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "code",
3
3
  "name": "code",
4
- "version": "3.10.4",
4
+ "version": "3.10.6",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
@@ -15,5 +15,5 @@
15
15
  "docs/memory/main.md",
16
16
  ".mcp.json"
17
17
  ],
18
- "digest": "4b1ec8d430ab"
18
+ "digest": "9198a225596a"
19
19
  }
@@ -31,7 +31,7 @@ import {
31
31
  recordTouched,
32
32
  } from "./lib/state.mjs";
33
33
  import { frozenStatus } from "./lib/frozen.mjs";
34
- import { approvedPackages } from "./lib/approved.mjs";
34
+ import { approvedPackages, rejectedPackages } from "./lib/approved.mjs";
35
35
  import {
36
36
  recordSession,
37
37
  liveSiblings,
@@ -484,15 +484,24 @@ function detectOutcomes(projectDir, meta, rows) {
484
484
  } catch (e) {
485
485
  continue;
486
486
  }
487
+ // A row is raised for a *rejected* package only — a decision already taken and violated.
488
+ // A package with no decision either way is not a defect: it is a dimension nobody has ruled
489
+ // on, harvested centrally by the fleet package census. Raising rows on absence turned the
490
+ // commit wall into a tax on every unlisted dependency and produced waivers, not decisions.
491
+ const rejected = rejectedPackages(projectDir);
487
492
  const names = [...Object.keys(manifest.dependencies || {}), ...Object.keys(manifest.devDependencies || {})];
488
493
  for (const name of names) {
489
- if (approved.has(name) || raised(`\`${name}\` (package-off-catalog`)) continue;
494
+ if (approved.has(name) || raised(`\`${name}\` (package-rejected`)) continue;
495
+ const no = rejected.get(name);
496
+ if (!no) continue;
490
497
  rows.push(
491
498
  formatAutoRow({
492
499
  ...meta,
493
- kind: "package-off-catalog",
494
- description: `${rel} declares \`${name}\` (package-off-catalog), absent from the approved table`,
495
- action: "remove the dependency, or add it to .claude/hooks/approved-packages.md by explicit user decision",
500
+ kind: "package-rejected",
501
+ description: `${rel} declares \`${name}\` (package-rejected), which the approved table rejects`,
502
+ action: no.use
503
+ ? `replace it with \`${no.use}\`, or change the decision in .claude/hooks/approved-packages.md`
504
+ : `use the choice ${no.rule} prescribes for this stack, or change the decision`,
496
505
  })
497
506
  );
498
507
  }
@@ -13,7 +13,7 @@ import { readQueue, countRows, askedThisSession, recordAsked } from "./lib/state
13
13
  import { frozenStatus } from "./lib/frozen.mjs";
14
14
  import { undischargedRows } from "./lib/ledger.mjs";
15
15
  import { appendEvent, unresolvedDenials, sessionLogPath, malformedEventLines } from "./lib/session-log.mjs";
16
- import { approvedPackages } from "./lib/approved.mjs";
16
+ import { approvedPackages, rejectedPackages } from "./lib/approved.mjs";
17
17
  import { claimActivity, releaseActivity, recentForeignTouches } from "./lib/claims.mjs";
18
18
 
19
19
  function readSafe(file) {
@@ -277,6 +277,7 @@ function packageGate(projectDir, command, sessionId) {
277
277
  if (!m) return null;
278
278
  const approved = approvedPackages(projectDir);
279
279
  if (approved.size === 0) return null; // no rules file installed — nothing to enforce
280
+ const rejected = rejectedPackages(projectDir);
280
281
  const raw = m[1] || "";
281
282
  // Test packages install at `latest`, outside the table's pin set (.claude/rules/testing.md).
282
283
  // Two dev-only installs are a decision already taken rather than a new one: a companion in an
@@ -291,16 +292,25 @@ function packageGate(projectDir, command, sessionId) {
291
292
  const args = raw.split(/\s+/).filter((a) => a && !a.startsWith("-") && PKG_SPEC.test(a));
292
293
  for (const arg of args) {
293
294
  const pkg = arg.startsWith("@") ? arg.split("@").slice(0, 2).join("@") : arg.split("@")[0];
295
+ if (approved.has(pkg)) continue;
294
296
  if (isDev && pkg.startsWith("@") && approvedScopes.has(pkg.split("/")[0])) continue;
295
- if (!approved.has(pkg)) {
297
+ // Approved wins over rejected: `jest` is banned for web and Express and prescribed for Expo,
298
+ // so a name in both is a prescription somewhere and must not be denied.
299
+ const no = rejected.get(pkg);
300
+ if (no) {
301
+ const instead = no.use
302
+ ? `Use \`${no.use}\`${no.version ? ` ${no.version}` : ""}${no.purpose ? ` — ${no.purpose}` : ""}.`
303
+ : `The prescribed choice for your stack is in ${no.rule}.`;
296
304
  return deny(
297
305
  projectDir,
298
306
  sessionId,
299
307
  "package-table",
300
308
  pkg,
301
- `BLOCKED — package table. \`${pkg}\` is not in the approved table (.claude/hooks/approved-packages.md; test packages: .claude/rules/testing.md). The table changes only by explicit user decision.`
309
+ `BLOCKED — \`${pkg}\` is rejected here. ${instead} Rejections are recorded in .claude/hooks/approved-packages.md; changing one is an explicit user decision.`
302
310
  );
303
311
  }
312
+ // No decision exists for this package — not approved, not rejected. Allowed, and harvested
313
+ // centrally by the fleet package census rather than blocked here.
304
314
  }
305
315
  // Every named package is approved — a package-table denial for one of them is a successful retry.
306
316
  for (const arg of args) {
@@ -56,3 +56,35 @@ export function approvedPackages(projectDir) {
56
56
  }
57
57
  return names;
58
58
  }
59
+
60
+ // A rejection is a decision already taken, and it names its replacement: the approved table's rows
61
+ // are `| package | version | use | don't use |`, so every name in a Don't-use cell points at that
62
+ // row's package. testing.md's framework table bans by stack without a single substitute, so those
63
+ // rejections carry the rule reference instead. Absence from both maps is not a rejection — it is a
64
+ // dimension nobody has ruled on, and the gate lets it through (the census harvests it centrally).
65
+ export function rejectedPackages(projectDir) {
66
+ const rejected = new Map();
67
+ const table = path.join(projectDir, ".claude", "hooks", "approved-packages.md");
68
+ for (const line of readSafe(table).split("\n")) {
69
+ const cells = line.split("|");
70
+ if (cells.length < 6) continue;
71
+ const pkg = /`([^`]+)`/.exec(cells[1] || "");
72
+ if (!pkg || pkg[1] === "Package" || pkg[1] === "---") continue;
73
+ const version = (/`?([^`|]+)`?/.exec((cells[2] || "").trim()) || [])[1] || "";
74
+ for (const m of (cells[4] || "").matchAll(/`(@?[a-z0-9][\w./-]*)`/g)) {
75
+ if (m[1] === pkg[1]) continue; // "`mermaid` via CDN" bans a delivery, not the row's own package
76
+ rejected.set(m[1], { use: pkg[1], version: version.trim(), purpose: (cells[3] || "").trim() });
77
+ }
78
+ }
79
+ const testing = path.join(projectDir, ".claude", "rules", "testing.md");
80
+ let banColumn = -1;
81
+ for (const line of readSafe(testing).split("\n")) {
82
+ banColumn = banColumnOf(line, banColumn);
83
+ if (banColumn < 0 || !line.trimStart().startsWith("|")) continue;
84
+ const cell = line.split("|")[banColumn] || "";
85
+ for (const m of cell.matchAll(/`(@?[a-z0-9][\w./-]*)`/g)) {
86
+ if (!rejected.has(m[1])) rejected.set(m[1], { rule: "testing.md § Frameworks by stack" });
87
+ }
88
+ }
89
+ return rejected;
90
+ }
@@ -11,6 +11,16 @@ Tailwind v4 + shadcn-based UI conventions for **web** apps — styling, the `@nu
11
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
12
  - Spacing in 2/4/6/8; `object-cover`; `truncate`; color through tokens — **never** hardcoded hex in components.
13
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
+
14
24
  ## TypeScript
15
25
 
16
26
  Every package compiles under `strict: true` and `noUncheckedIndexedAccess: true`.
@@ -1,6 +1,29 @@
1
1
  # General Standards
2
2
 
3
- Runtime versions, package policy, the API response envelope, and GitHub Actions pins — the workspace baseline.
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.
4
27
 
5
28
  ## Runtime Versions
6
29
 
@@ -10,7 +33,17 @@ Runtime versions, package policy, the API response envelope, and GitHub Actions
10
33
 
11
34
  ## Packages
12
35
 
13
- **Only packages in the approved table** — `.claude/hooks/approved-packages.md`, enforced by the PreToolUse install gate, which names the file on a block. The table changes only by explicit user decision.
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/*`.
14
47
 
15
48
  ## API Response Envelope
16
49
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "harness": "etna",
3
3
  "name": "etna",
4
- "version": "1.5.2",
4
+ "version": "1.5.4",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "615e0382294b"
11
+ "digest": "b76f5c26d6a9"
12
12
  }
@@ -1,6 +1,29 @@
1
1
  # General Standards
2
2
 
3
- Runtime versions, package policy, the API response envelope, and GitHub Actions pins — the workspace baseline.
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.
4
27
 
5
28
  ## Runtime Versions
6
29
 
@@ -10,7 +33,17 @@ Runtime versions, package policy, the API response envelope, and GitHub Actions
10
33
 
11
34
  ## Packages
12
35
 
13
- **Only packages in the approved table** — `.claude/hooks/approved-packages.md`, enforced by the PreToolUse install gate, which names the file on a block. The table changes only by explicit user decision.
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/*`.
14
47
 
15
48
  ## API Response Envelope
16
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.19.78",
3
+ "version": "0.19.80",
4
4
  "description": "Bootstrap a project with a Nurix harness — copies the harness's CLAUDE.md, skills, agents, rules, and hooks directly into the project from a bundled payload (no marketplace fetch, no network).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,5 +42,5 @@
42
42
  "agent",
43
43
  "plugin"
44
44
  ],
45
- "etnaFingerprint": "728af89f1e8e"
45
+ "etnaFingerprint": "f0d6766a263f"
46
46
  }