@nurix/etna 0.19.53 → 0.19.54

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": "b90b7910764f",
2
+ "fingerprint": "70b47e452344",
3
3
  "harnesses": {
4
4
  "apollo": {
5
5
  "plugin": "apollo",
@@ -0,0 +1,26 @@
1
+ # dropped Claude tools: Read, Glob, Grep, Edit, Write, Bash, Skill
2
+ name = "bookkeeper"
3
+ description = "Drains docs/operations/bookkeeping.md — performs mechanical fixes (owns: globs, header comments, doc-lint findings, index rows), expands note: rows into doc edits, marks rows resolved. Dispatched by the main session at turn end or commit time; never self-triggers. Triggers on \"drain the bookkeeping queue\", \"dispatch the bookkeeper\", \"/bookkeeper\"."
4
+ developer_instructions = '''
5
+
6
+ # Bookkeeper Agent
7
+
8
+ You drain the shared work queue at `docs/operations/bookkeeping.md`. Each row is one finding; your job is to resolve it — by doing the work it names — and delete the row. You are the asynchronous half of the harness's enforcement loop: the detector appends rows silently, you drain them, and the Stop/commit walls hold until the queue is clean or waived.
9
+
10
+ ## Source-of-truth order — the repo first, the transcript last
11
+
12
+ Ground every edit in this ladder, in order. Never invent content; a fact you cannot ground is a fact you do not write.
13
+
14
+ 1. **`auto:` rows are recomputable from code + docs on any machine — fix directly.** A `dead-glob` row: read the owning doc, find where the files went (`git log --follow`, glob search), update its `owns:` frontmatter. A `header-missing` row: add the file-path header comment in the file's native comment syntax. A `doc-lint` row: fix the finding, or add `<!-- doc-lint-disable: id -->` with a reason beside it when the flagged text is deliberate. An `index-row-missing` row: add the doc's row to `docs/README.md`. A `doc-implicated` row: read the diff of the named source file (`git diff` / `git log -p`), and update the owning doc **only where the diff contradicts documented behavior**; if nothing documented changed, the row's resolution is deleting it.
15
+ 2. **`note:` rows — expand strictly from the stated context.** The one-line context after the area is everything the session chose to hand you; land it in the destination the row names (`lands-in:` an owning doc, the backlog, or memory), written in that document's genre.
16
+ 3. **A thin note, same machine only** — derive the transcript from the row's `sid=` tail: the session JSONL lives at `~/.claude/projects/{project-dir-slug}/{sid}.jsonl` (the slug is the project path with `/` → `-`). Search it for the row's file path or area and read only that window — never the whole file. The sid is best-effort and dead on other clones — expected; treat an unreadable or missing transcript as "no window".
17
+ 4. **Still ungrounded** — replace the row with a `needs-session:` row (`- needs-session: {date} · {what you could not ground} → main session resolves | refs={original row}`) and stop working that row.
18
+
19
+ ## Rules
20
+
21
+ - **Never edit frozen records** — committed changelog entries, `Status: Complete|Abandoned` plans, outcome-stamped runbooks. A finding that seems to require one gets a `needs-session:` row instead.
22
+ - **Every touched doc goes through the `documentation` skill's rules** — present tense, two-natures, no history narration in truth documents.
23
+ - **Resolution = deletion.** A finished row is removed from the queue, not annotated. Only `waived:` conversions (a reason plus `expires=YYYY-MM-DD`) and `needs-session:` rows survive your pass.
24
+ - **Never waive on your own judgment** — waiving is the main session's (or the user's) call; your outcomes are fixed, landed, or `needs-session:`.
25
+ - **Report by row**: one line each — the row, the outcome (fixed / landed in {doc} / needs-session), and the files touched. The main session relays this; keep it terse.
26
+ '''
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "apollo",
3
3
  "name": "apollo",
4
- "version": "1.1.3",
4
+ "version": "1.1.5",
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": "95f2ab8bd94e"
18
+ "digest": "e86ea39dc854"
19
19
  }
@@ -240,7 +240,7 @@ function hasHeaderComment(projectDir, rel) {
240
240
 
241
241
  function detectorForFile(projectDir, rel, meta, rows) {
242
242
  const owners = owningDocs(projectDir, rel);
243
- recordTouched(projectDir, rel, owners.map((o) => o.doc));
243
+ recordTouched(projectDir, rel, owners.map((o) => o.doc), meta.sessionId);
244
244
 
245
245
  if (rel.endsWith(".md")) {
246
246
  // An edited doc resolves any doc-implicated row that pointed at it.
@@ -1,7 +1,7 @@
1
1
  // harness/code/hooks/compliance.mjs — the gate-always-waivable hook module.
2
2
  // PreToolUse (Edit|Write|MultiEdit|Bash): deny what is never legitimate (frozen records, protected
3
3
  // files, plan-gate skips, changelog-less commits, off-catalog packages), ask where intent matters
4
- // (branches, live commands, test files mid-plan, off-map doc paths). Stop: block an arc that tries
4
+ // (branches, live commands). Stop: block an arc that tries
5
5
  // to end with unresolved bookkeeping or an unrecorded plan completion. Every gate fails open — a
6
6
  // crash or unreadable repo allows the action; enforcement is never allowed to become an outage.
7
7
 
@@ -70,12 +70,16 @@ function openGatedPlans(projectDir) {
70
70
  if (!status || /Complete|Abandoned/.test(status)) continue;
71
71
  const gates = (/^\*\*Gates:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
72
72
  if (!gates) continue; // legacy tolerance
73
- plans.push({ file, status, gates, hasLedger: /^##\s+Test Obligations\b/m.test(text) });
73
+ plans.push({
74
+ file,
75
+ status,
76
+ gates,
77
+ hasLedger: /^##\s+Test Obligations\b/m.test(text),
78
+ });
74
79
  }
75
80
  return plans;
76
81
  }
77
82
 
78
- const TEST_FILE_RE = /(^|\/)tests?\/|\.test\.|\.spec\./;
79
83
  const SOURCE_LIKE = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|vue|svelte)$/;
80
84
 
81
85
  function fileGate(projectDir, rel, toolName) {
@@ -109,15 +113,13 @@ function fileGate(projectDir, rel, toolName) {
109
113
  }
110
114
  }
111
115
 
112
- if (toolName === "Write" && TEST_FILE_RE.test(rel)) {
113
- const midPlan = openGatedPlans(projectDir).some((p) => /In Progress/i.test(p.status));
114
- if (midPlan) {
115
- return decision(
116
- "ask",
117
- "Create a test file mid-plan? Plan builds defer tests to the verification gate — proceed only if this is a bug-fix regression test (fails before the fix, passes after)."
118
- );
119
- }
120
- }
116
+ // Writing a test file is never an interruption. Deferring tests to the verification gate stays
117
+ // doctrine (`verification.md` § During the loop), but it needs no gate here: what the ledger
118
+ // actually guarantees is enforced at both ends already — a plan may not take implementation
119
+ // edits until its `## Test Obligations` section exists (above), and it may not reach
120
+ // `Status: Complete` until the gate discharges every row (the Stop gate). Policing the *moment*
121
+ // a file appears adds no guarantee those two do not, and it fires hardest at the gate itself,
122
+ // where the ledger's tests are precisely what should be getting written.
121
123
 
122
124
  // Doc placement is never an interruption: an off-map docs/ folder is detected silently by
123
125
  // the awareness module (a `doc-placement` auto: row) and resolved by the bookkeeper.
@@ -272,12 +274,18 @@ function bookkeeperDispatched(transcriptPath) {
272
274
 
273
275
  // Plans this session flipped to Complete: Status is Complete AND the file appears in this
274
276
  // machine's touched.tsv (written by the awareness detector as edits happen).
275
- function plansCompletedThisSession(projectDir) {
277
+ function plansCompletedThisSession(projectDir, sessionId) {
276
278
  const touched = readSafe(path.join(projectDir, ".claude", "state", "touched.tsv"));
277
279
  const touchedPlans = new Set(
278
280
  touched
279
281
  .split("\n")
280
- .map((line) => line.split("\t")[1])
282
+ .map((line) => line.split("\t"))
283
+ // THIS session's rows only. touched.tsv is shared by every session in the checkout, so an
284
+ // unfiltered read blocks one session's Stop for a plan another session completed — whose Ask
285
+ // quotes and iterations it does not have and cannot invent. Rows written before the id column
286
+ // existed carry no session and belong to none, so they are skipped rather than assumed.
287
+ .filter((cols) => sessionId && cols[3] === sessionId)
288
+ .map((cols) => cols[1])
281
289
  .filter((p) => p && p.startsWith("docs/history/plans/"))
282
290
  );
283
291
  const out = [];
@@ -288,6 +296,30 @@ function plansCompletedThisSession(projectDir) {
288
296
  return out;
289
297
  }
290
298
 
299
+ // Whether some changelog entry already accounts for this plan.
300
+ //
301
+ // COMMITTED COUNTS. The test used to be `git status --porcelain docs/history/changelog/` — i.e. "is
302
+ // there a DIRTY file there" — which a properly committed entry can never satisfy: committing cleans
303
+ // the tree, so the gate re-fired immediately after the one action that discharged it, and the only
304
+ // way to silence it was to leave an uncommitted file lying in the changelog directory.
305
+ //
306
+ // An entry names its plan by slug (the template links `../plans/<slug>.md`), so the slug is what is
307
+ // searched for. A dirty file still counts too: a draft being written this moment has not necessarily
308
+ // named its plan yet, and that was the original check's one real job.
309
+ function planHasChangelogEntry(projectDir, planRel) {
310
+ if ((git(projectDir, ["status", "--porcelain", "--", "docs/history/changelog/"]) || "").trim()) return true;
311
+
312
+ const slug = path.basename(planRel, ".md");
313
+ const dir = path.join(projectDir, "docs", "history", "changelog");
314
+ let names = [];
315
+ try {
316
+ names = fs.readdirSync(dir);
317
+ } catch {
318
+ return false;
319
+ }
320
+ return names.some((name) => name.endsWith(".md") && readSafe(path.join(dir, name)).includes(slug));
321
+ }
322
+
291
323
  function stopGate(projectDir, payload) {
292
324
  if (payload.stop_hook_active) return null; // the runtime's infinite-loop guard — never re-block
293
325
 
@@ -307,15 +339,14 @@ function stopGate(projectDir, payload) {
307
339
  };
308
340
  }
309
341
 
310
- const completed = plansCompletedThisSession(projectDir);
311
- if (completed.length > 0) {
312
- const drafts = git(projectDir, ["status", "--porcelain", "--", "docs/history/changelog/"]) || "";
313
- if (!drafts.trim()) {
314
- return {
315
- decision: "block",
316
- reason: `Plan \`${completed[0]}\` reached Status: Complete this session with no changelog entry drafted. Write the draft now (documentation skill → templates/changelog-entry.md) — the Ask quotes, rejected iterations, and skill verdicts that fill it exist only in this session and die with it.`,
317
- };
318
- }
342
+ const undocumented = plansCompletedThisSession(projectDir, payload.session_id).filter(
343
+ (rel) => !planHasChangelogEntry(projectDir, rel)
344
+ );
345
+ if (undocumented.length > 0) {
346
+ return {
347
+ decision: "block",
348
+ reason: `Plan \`${undocumented[0]}\` reached Status: Complete this session with no changelog entry drafted. Write the draft now (documentation skill → templates/changelog-entry.md) — the Ask quotes, rejected iterations, and skill verdicts that fill it exist only in this session and die with it.`,
349
+ };
319
350
  }
320
351
 
321
352
  return null;
@@ -150,8 +150,14 @@ export function recordAsked(projectDir, sessionId, key) {
150
150
  }
151
151
 
152
152
  // Append one tab-separated line to .claude/state/touched.tsv (file → owning doc map for the session).
153
- export function recordTouched(projectDir, relPath, docs) {
153
+ //
154
+ // THE SESSION ID IS THE FOURTH COLUMN, and it is what lets a reader tell one session's edits from
155
+ // another's. The file is shared by every session running in this checkout, so without it a gate that
156
+ // asks "did I touch this?" can only answer "did anyone?" — and blocks one session for another's
157
+ // work. Appended rather than inserted so rows written before this column existed still parse: the
158
+ // path stays at index 1, and a missing id reads as `undefined`, which belongs to no session.
159
+ export function recordTouched(projectDir, relPath, docs, sessionId) {
154
160
  const dir = stateDir(projectDir);
155
- const line = `${new Date().toISOString()}\t${relPath}\t${docs.join(",") || "-"}\n`;
161
+ const line = `${new Date().toISOString()}\t${relPath}\t${docs.join(",") || "-"}\t${sessionId || "-"}\n`;
156
162
  fs.appendFileSync(path.join(dir, "touched.tsv"), line);
157
163
  }
@@ -1,6 +1,6 @@
1
1
  # Database
2
2
 
3
- Fixed conventions for relational schemas in **every** Nurix project — what earns a table, the key vocabulary, foreign-key doctrine, naming, and column types. A cross-project consistency rule: **one concept, one name, everywhere**. General code naming (variables, files, casing) is [`nomenclature.md`](nomenclature.md); this rule owns the database.
3
+ Fixed conventions for relational schemas in **every** Nurix project — what earns a table, where a fact lives once it does, the key vocabulary, foreign-key doctrine, naming, and column types. A cross-project consistency rule: **one concept, one name, everywhere**. General code naming (variables, files, casing) is [`nomenclature.md`](nomenclature.md); this rule owns the database.
4
4
 
5
5
  Written for SQLite / Cloudflare D1 + Drizzle (the concrete types below). The names and key vocabulary are engine-agnostic — a Postgres project keeps the same column names and semantics with native types (`uuid`, `timestamptz`, `boolean`).
6
6
 
@@ -10,29 +10,64 @@ A table is the most expensive home for data — a migration to change its shape,
10
10
 
11
11
  | A table is earned by… | Lacking all of these, the data belongs in… |
12
12
  | -------------------------------------------------------------------------- | ------------------------------------------------------- |
13
- | identity minted at **runtime** | — |
14
- | variation **per tenant / user** | — |
13
+ | identity minted at **runtime** — a session, an upload | — |
14
+ | variation **per tenant / user** — a workspace's theme | — |
15
15
  | **independent addressing** — queried, indexed, or FK'd in isolation | a **JSON column** on its parent row |
16
16
  | **concurrency-safe** writes — racing writers must not clobber a shared blob | — |
17
17
  | a **DB-enforced invariant** — immutability trigger, unique, atomic boundary | — |
18
- | dev-authored, published on deploy, identical for every tenant | **config / a constants file**, validated at build |
19
- | exactly recomputable from other rows | a **cache** (KV / edge), recomputed on demand |
20
- | process state that is dead once the request completes | a **log or workflow engine**, never the domain DB |
21
- | owned by another system-of-record | a **reference column** — store the id, not the data |
18
+ | dev-authored, published on deploy, identical for every tenant — plan tiers | **config / a constants file**, validated at build |
19
+ | exactly recomputable from other rows — a tenant's usage total | a **cache** (KV / edge), recomputed on demand |
20
+ | process state dead once the request completes — a retry count | a **log or workflow engine**, never the domain DB |
21
+ | owned by another system-of-record — the payment processor's invoice | a **reference column** — store the id, not the data |
22
22
 
23
23
  Each right-column route is one test: process state — would a product feature break if this vanished the instant the request finished?; derived output — exactly recomputable from other rows + code?; un-addressed detail — is the row ever `SELECT`ed / `JOIN`ed / FK'd alone?; another system's record — does a specialized service (chat, telemetry, object store) already own it?
24
24
 
25
- - **Static vocabulary or a fixed graph → config, not a table.** Surface it in code as a constant/type; reifying it into tables plus a seed makes the file and the tables a two-copy sync problem. The refined boundary is § Vocabulary / lookup tables.
25
+ - **Static vocabulary or a fixed graph → config, not a table** — HTTP status labels, a fixed onboarding funnel. Surface it in code as a constant/type; reifying it into tables plus a seed makes the file and the tables a two-copy sync problem. The refined boundary is § Vocabulary / lookup tables.
26
26
  - **History → one immutable log + a mutable head.** Never shatter versioning into per-entity `*_revisions` / `*_revision_sections` tables — snapshot the coherent set into one append-only versions table; current state is a projection of the newest entry. A table that is just "old rows of another table" collapses into the snapshot.
27
27
  - **A surviving table names the invariant it buys** — content-addressed immutable rows buy reproducibility, a monotonic sequence buys ordering/sync, an atomic write boundary buys multi-row atomicity. Drop the requirement and the table drops with it.
28
28
 
29
29
  Table count tracks **requirements, not domain richness** — a rich domain is not entitled to a table per noun.
30
30
 
31
+ ## State, rules, and history
32
+
33
+ **One fact has one home, chosen by what kind of fact it is.** Confusing these produces the most common redundant table in a schema — a queue whose rows restate state the entity already carries.
34
+
35
+ | The fact | Home | Example |
36
+ | --- | --- | --- |
37
+ | What the entity **is now** | a **column on the entity's own row** | `documents.state = 'blocked'` |
38
+ | What may be **done** about that state | **code** — the transition map, the UI's affordances | which inputs unblock a document |
39
+ | What **was done**, and by whom | the **append-only audit log** | an `audit_log` row: `document.override_applied` |
40
+ | What a consumer must **replay in order** | the **sequence log** | an `app_events` row keyed `(app_id, sequence)` |
41
+
42
+ - **State becomes a column the moment anything selects on it.** `WHERE state = 'blocked'` takes an index; `WHERE json_extract(metadata, '$.state') = 'blocked'` scans the table and is invisible to every schema check. JSON keeps only what § What earns a table leaves it — detail nothing addresses alone.
43
+ - **A pending-work table mirroring row state is the anti-pattern** — `*_actions`, `*_inbox`, `*_flags`. The tell: resolving one of its rows also updates the entity, so two rows answer one question and are free to disagree. The *open question* is a column beside the state (at most one per row ⇒ a column, § A junction spells many-to-many); the *answer given* is an audit-log row.
44
+ - **Legal transitions live in code, never as rows** — a `status_transitions` table is a state machine the database cannot enforce and the code must re-implement anyway to use.
45
+
46
+ **The two logs are not substitutes**, and no one table serves both:
47
+
48
+ | | Sequence log | Audit log |
49
+ | --- | --- | --- |
50
+ | Answers | what happened here, **in order** | who did what, **and whether it succeeded** |
51
+ | Ordinal | a **gapless per-entity `sequence`**, allocated from a counter | none — nothing replays it |
52
+ | Scope | one entity, always | any — **including none**: a sign-up predates every tenant |
53
+ | Records | only what **happened** | **attempts too** — `denied`, `failure` |
54
+ | Read by | cursors replaying forward | investigations, at any age |
55
+
56
+ The bottom two rows are why neither can hold the other's rows: an event at a scope with no entity has no sequence to take, and a refused attempt in a replay stream would have consumers apply something that never happened.
57
+
58
+ ### The audit log's own shape
59
+
60
+ **An audit log outlives every row it names, so it stores what it needs to stay readable without them.** Both consequences are missing from most first drafts:
61
+
62
+ - **Denormalize the display at write time** — `actor_label`, `target_label` beside `actor_id` / `target_id`. Ids alone leave the log intact but unreadable once the person or record is deleted, which is the one thing it exists to survive.
63
+ - **Record the attempt, not the success** — `outcome` (`success | failure | denied`) plus a `reason`. Refused sign-ins and permission denials are most of what anyone opens an audit log for; without the column they cannot be written at all.
64
+ - **Keep the columns product-neutral** — `actor_*` · `action` · `outcome` · `target_*` · `scope_path` · `channel` · `source_ip` · `correlation_id` · `changes_json` serve any domain. Per-level tenancy columns tie the log to today's model; a materialized `scope_path` (`workspace:abc/app:def`) makes "everything under this tenant" one prefix scan and survives a new level appearing — at the price of enforcing the hierarchy in code rather than a `CHECK`.
65
+
31
66
  ## Scope, not parallel tables
32
67
 
33
68
  One entity that exists at several scopes is **one table with a nullable scope column** — never one table per scope, and never one table shared across entity families.
34
69
 
35
- - **A table shared by several families carries the union of their columns** — almost nothing can be `NOT NULL`, the payload lands in JSON beyond every constraint, index, and FK, and a new family widens a table other features already depend on. One table per family.
70
+ - **A table shared by several families carries the union of their columns** — one `settings` table for users, apps, and workspaces. Almost nothing can be `NOT NULL`, the payload lands in JSON beyond every constraint, index, and FK, and a new family widens a table other features already depend on. One table per family.
36
71
  - **Two tables declared to have "the same columns" are two schemas maintained by hand** — the drift is silent, surfacing as an incomplete copy, never as an error. Collapse them and discriminate by column.
37
72
  - **The scope column is the discriminator** (`app_id IS NULL` = parent scope, set = child scope). Copying a scope is then `INSERT … SELECT` over one table with one column rewritten, so source and target cannot disagree about shape.
38
73
  - **A nullable owner column is how a row exists before its owner does** — a pending enrolment or unclaimed record is creatable before the owner is known, which is what lets a `pending_*` / `*_requests` table collapse into the real one.
@@ -49,7 +84,7 @@ One entity that exists at several scopes is **one table with a nullable scope co
49
84
 
50
85
  Three column roles are fixed across every table and every project:
51
86
 
52
- - **`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.
87
+ - **`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.
53
88
  - **`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 rows between scopes). ❌ Never rename this role `handle`, `key`, `code`, or `name` in another project.
54
89
  - **`label`** — the **human display name** — ❌ never `name`, `title`, or `display_name`.
55
90
 
@@ -118,6 +153,6 @@ CREATE UNIQUE INDEX <t>_child_slug_unique ON <t> (child_id, slug) WHERE child_
118
153
 
119
154
  ## Vocabulary / lookup tables
120
155
 
121
- A controlled vocabulary earns a **table** only when a user row FKs into it and reads its `label` / `description` at runtime; static dev-authored config nobody references by FK stays **config** (§ What earns a table) — enumerable is not tabular, and a `type`-style column validated against the constants file beats an FK to a seeded mirror. An earned table has a fixed shape: `id` (UUID) · `slug` (UNIQUE) · `label` · optional `description` · `status` · timestamps.
156
+ A controlled vocabulary earns a **table** only when a user row FKs into it and reads its `label` / `description` at runtime — a service catalog rendered in a picker; static dev-authored config nobody references by FK stays **config** (§ What earns a table) — a `doc_type` value list. Enumerable is not tabular, and a `type`-style column validated against the constants file beats an FK to a seeded mirror. An earned table has a fixed shape: `id` (UUID) · `slug` (UNIQUE) · `label` · optional `description` · `status` · timestamps.
122
157
 
123
158
  - **Seeds are generated, never hand-written** — the source of truth is a CSV or a shared package; a generator emits the `*.seed.sql` with UUIDv5 ids and an idempotent `ON CONFLICT(slug)` upsert that preserves live counts / admin-tuned columns. ❌ Hand-editing seed SQL — regenerate it.
@@ -0,0 +1,26 @@
1
+ # dropped Claude tools: Read, Glob, Grep, Edit, Write, Bash, Skill
2
+ name = "bookkeeper"
3
+ description = "Drains docs/operations/bookkeeping.md — performs mechanical fixes (owns: globs, header comments, doc-lint findings, index rows), expands note: rows into doc edits, marks rows resolved. Dispatched by the main session at turn end or commit time; never self-triggers. Triggers on \"drain the bookkeeping queue\", \"dispatch the bookkeeper\", \"/bookkeeper\"."
4
+ developer_instructions = '''
5
+
6
+ # Bookkeeper Agent
7
+
8
+ You drain the shared work queue at `docs/operations/bookkeeping.md`. Each row is one finding; your job is to resolve it — by doing the work it names — and delete the row. You are the asynchronous half of the harness's enforcement loop: the detector appends rows silently, you drain them, and the Stop/commit walls hold until the queue is clean or waived.
9
+
10
+ ## Source-of-truth order — the repo first, the transcript last
11
+
12
+ Ground every edit in this ladder, in order. Never invent content; a fact you cannot ground is a fact you do not write.
13
+
14
+ 1. **`auto:` rows are recomputable from code + docs on any machine — fix directly.** A `dead-glob` row: read the owning doc, find where the files went (`git log --follow`, glob search), update its `owns:` frontmatter. A `header-missing` row: add the file-path header comment in the file's native comment syntax. A `doc-lint` row: fix the finding, or add `<!-- doc-lint-disable: id -->` with a reason beside it when the flagged text is deliberate. An `index-row-missing` row: add the doc's row to `docs/README.md`. A `doc-implicated` row: read the diff of the named source file (`git diff` / `git log -p`), and update the owning doc **only where the diff contradicts documented behavior**; if nothing documented changed, the row's resolution is deleting it.
15
+ 2. **`note:` rows — expand strictly from the stated context.** The one-line context after the area is everything the session chose to hand you; land it in the destination the row names (`lands-in:` an owning doc, the backlog, or memory), written in that document's genre.
16
+ 3. **A thin note, same machine only** — derive the transcript from the row's `sid=` tail: the session JSONL lives at `~/.claude/projects/{project-dir-slug}/{sid}.jsonl` (the slug is the project path with `/` → `-`). Search it for the row's file path or area and read only that window — never the whole file. The sid is best-effort and dead on other clones — expected; treat an unreadable or missing transcript as "no window".
17
+ 4. **Still ungrounded** — replace the row with a `needs-session:` row (`- needs-session: {date} · {what you could not ground} → main session resolves | refs={original row}`) and stop working that row.
18
+
19
+ ## Rules
20
+
21
+ - **Never edit frozen records** — committed changelog entries, `Status: Complete|Abandoned` plans, outcome-stamped runbooks. A finding that seems to require one gets a `needs-session:` row instead.
22
+ - **Every touched doc goes through the `documentation` skill's rules** — present tense, two-natures, no history narration in truth documents.
23
+ - **Resolution = deletion.** A finished row is removed from the queue, not annotated. Only `waived:` conversions (a reason plus `expires=YYYY-MM-DD`) and `needs-session:` rows survive your pass.
24
+ - **Never waive on your own judgment** — waiving is the main session's (or the user's) call; your outcomes are fixed, landed, or `needs-session:`.
25
+ - **Report by row**: one line each — the row, the outcome (fixed / landed in {doc} / needs-session), and the files touched. The main session relays this; keep it terse.
26
+ '''
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "code",
3
3
  "name": "code",
4
- "version": "3.6.3",
4
+ "version": "3.6.5",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
@@ -10,5 +10,5 @@
10
10
  "seeds": [
11
11
  ".mcp.json"
12
12
  ],
13
- "digest": "4322e6d1c781"
13
+ "digest": "cc0eb9871a83"
14
14
  }
@@ -240,7 +240,7 @@ function hasHeaderComment(projectDir, rel) {
240
240
 
241
241
  function detectorForFile(projectDir, rel, meta, rows) {
242
242
  const owners = owningDocs(projectDir, rel);
243
- recordTouched(projectDir, rel, owners.map((o) => o.doc));
243
+ recordTouched(projectDir, rel, owners.map((o) => o.doc), meta.sessionId);
244
244
 
245
245
  if (rel.endsWith(".md")) {
246
246
  // An edited doc resolves any doc-implicated row that pointed at it.
@@ -1,7 +1,7 @@
1
1
  // harness/code/hooks/compliance.mjs — the gate-always-waivable hook module.
2
2
  // PreToolUse (Edit|Write|MultiEdit|Bash): deny what is never legitimate (frozen records, protected
3
3
  // files, plan-gate skips, changelog-less commits, off-catalog packages), ask where intent matters
4
- // (branches, live commands, test files mid-plan, off-map doc paths). Stop: block an arc that tries
4
+ // (branches, live commands). Stop: block an arc that tries
5
5
  // to end with unresolved bookkeeping or an unrecorded plan completion. Every gate fails open — a
6
6
  // crash or unreadable repo allows the action; enforcement is never allowed to become an outage.
7
7
 
@@ -70,12 +70,16 @@ function openGatedPlans(projectDir) {
70
70
  if (!status || /Complete|Abandoned/.test(status)) continue;
71
71
  const gates = (/^\*\*Gates:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
72
72
  if (!gates) continue; // legacy tolerance
73
- plans.push({ file, status, gates, hasLedger: /^##\s+Test Obligations\b/m.test(text) });
73
+ plans.push({
74
+ file,
75
+ status,
76
+ gates,
77
+ hasLedger: /^##\s+Test Obligations\b/m.test(text),
78
+ });
74
79
  }
75
80
  return plans;
76
81
  }
77
82
 
78
- const TEST_FILE_RE = /(^|\/)tests?\/|\.test\.|\.spec\./;
79
83
  const SOURCE_LIKE = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|vue|svelte)$/;
80
84
 
81
85
  function fileGate(projectDir, rel, toolName) {
@@ -109,15 +113,13 @@ function fileGate(projectDir, rel, toolName) {
109
113
  }
110
114
  }
111
115
 
112
- if (toolName === "Write" && TEST_FILE_RE.test(rel)) {
113
- const midPlan = openGatedPlans(projectDir).some((p) => /In Progress/i.test(p.status));
114
- if (midPlan) {
115
- return decision(
116
- "ask",
117
- "Create a test file mid-plan? Plan builds defer tests to the verification gate — proceed only if this is a bug-fix regression test (fails before the fix, passes after)."
118
- );
119
- }
120
- }
116
+ // Writing a test file is never an interruption. Deferring tests to the verification gate stays
117
+ // doctrine (`verification.md` § During the loop), but it needs no gate here: what the ledger
118
+ // actually guarantees is enforced at both ends already — a plan may not take implementation
119
+ // edits until its `## Test Obligations` section exists (above), and it may not reach
120
+ // `Status: Complete` until the gate discharges every row (the Stop gate). Policing the *moment*
121
+ // a file appears adds no guarantee those two do not, and it fires hardest at the gate itself,
122
+ // where the ledger's tests are precisely what should be getting written.
121
123
 
122
124
  // Doc placement is never an interruption: an off-map docs/ folder is detected silently by
123
125
  // the awareness module (a `doc-placement` auto: row) and resolved by the bookkeeper.
@@ -272,12 +274,18 @@ function bookkeeperDispatched(transcriptPath) {
272
274
 
273
275
  // Plans this session flipped to Complete: Status is Complete AND the file appears in this
274
276
  // machine's touched.tsv (written by the awareness detector as edits happen).
275
- function plansCompletedThisSession(projectDir) {
277
+ function plansCompletedThisSession(projectDir, sessionId) {
276
278
  const touched = readSafe(path.join(projectDir, ".claude", "state", "touched.tsv"));
277
279
  const touchedPlans = new Set(
278
280
  touched
279
281
  .split("\n")
280
- .map((line) => line.split("\t")[1])
282
+ .map((line) => line.split("\t"))
283
+ // THIS session's rows only. touched.tsv is shared by every session in the checkout, so an
284
+ // unfiltered read blocks one session's Stop for a plan another session completed — whose Ask
285
+ // quotes and iterations it does not have and cannot invent. Rows written before the id column
286
+ // existed carry no session and belong to none, so they are skipped rather than assumed.
287
+ .filter((cols) => sessionId && cols[3] === sessionId)
288
+ .map((cols) => cols[1])
281
289
  .filter((p) => p && p.startsWith("docs/history/plans/"))
282
290
  );
283
291
  const out = [];
@@ -288,6 +296,30 @@ function plansCompletedThisSession(projectDir) {
288
296
  return out;
289
297
  }
290
298
 
299
+ // Whether some changelog entry already accounts for this plan.
300
+ //
301
+ // COMMITTED COUNTS. The test used to be `git status --porcelain docs/history/changelog/` — i.e. "is
302
+ // there a DIRTY file there" — which a properly committed entry can never satisfy: committing cleans
303
+ // the tree, so the gate re-fired immediately after the one action that discharged it, and the only
304
+ // way to silence it was to leave an uncommitted file lying in the changelog directory.
305
+ //
306
+ // An entry names its plan by slug (the template links `../plans/<slug>.md`), so the slug is what is
307
+ // searched for. A dirty file still counts too: a draft being written this moment has not necessarily
308
+ // named its plan yet, and that was the original check's one real job.
309
+ function planHasChangelogEntry(projectDir, planRel) {
310
+ if ((git(projectDir, ["status", "--porcelain", "--", "docs/history/changelog/"]) || "").trim()) return true;
311
+
312
+ const slug = path.basename(planRel, ".md");
313
+ const dir = path.join(projectDir, "docs", "history", "changelog");
314
+ let names = [];
315
+ try {
316
+ names = fs.readdirSync(dir);
317
+ } catch {
318
+ return false;
319
+ }
320
+ return names.some((name) => name.endsWith(".md") && readSafe(path.join(dir, name)).includes(slug));
321
+ }
322
+
291
323
  function stopGate(projectDir, payload) {
292
324
  if (payload.stop_hook_active) return null; // the runtime's infinite-loop guard — never re-block
293
325
 
@@ -307,15 +339,14 @@ function stopGate(projectDir, payload) {
307
339
  };
308
340
  }
309
341
 
310
- const completed = plansCompletedThisSession(projectDir);
311
- if (completed.length > 0) {
312
- const drafts = git(projectDir, ["status", "--porcelain", "--", "docs/history/changelog/"]) || "";
313
- if (!drafts.trim()) {
314
- return {
315
- decision: "block",
316
- reason: `Plan \`${completed[0]}\` reached Status: Complete this session with no changelog entry drafted. Write the draft now (documentation skill → templates/changelog-entry.md) — the Ask quotes, rejected iterations, and skill verdicts that fill it exist only in this session and die with it.`,
317
- };
318
- }
342
+ const undocumented = plansCompletedThisSession(projectDir, payload.session_id).filter(
343
+ (rel) => !planHasChangelogEntry(projectDir, rel)
344
+ );
345
+ if (undocumented.length > 0) {
346
+ return {
347
+ decision: "block",
348
+ reason: `Plan \`${undocumented[0]}\` reached Status: Complete this session with no changelog entry drafted. Write the draft now (documentation skill → templates/changelog-entry.md) — the Ask quotes, rejected iterations, and skill verdicts that fill it exist only in this session and die with it.`,
349
+ };
319
350
  }
320
351
 
321
352
  return null;
@@ -150,8 +150,14 @@ export function recordAsked(projectDir, sessionId, key) {
150
150
  }
151
151
 
152
152
  // Append one tab-separated line to .claude/state/touched.tsv (file → owning doc map for the session).
153
- export function recordTouched(projectDir, relPath, docs) {
153
+ //
154
+ // THE SESSION ID IS THE FOURTH COLUMN, and it is what lets a reader tell one session's edits from
155
+ // another's. The file is shared by every session running in this checkout, so without it a gate that
156
+ // asks "did I touch this?" can only answer "did anyone?" — and blocks one session for another's
157
+ // work. Appended rather than inserted so rows written before this column existed still parse: the
158
+ // path stays at index 1, and a missing id reads as `undefined`, which belongs to no session.
159
+ export function recordTouched(projectDir, relPath, docs, sessionId) {
154
160
  const dir = stateDir(projectDir);
155
- const line = `${new Date().toISOString()}\t${relPath}\t${docs.join(",") || "-"}\n`;
161
+ const line = `${new Date().toISOString()}\t${relPath}\t${docs.join(",") || "-"}\t${sessionId || "-"}\n`;
156
162
  fs.appendFileSync(path.join(dir, "touched.tsv"), line);
157
163
  }
@@ -1,6 +1,6 @@
1
1
  # Database
2
2
 
3
- Fixed conventions for relational schemas in **every** Nurix project — what earns a table, the key vocabulary, foreign-key doctrine, naming, and column types. A cross-project consistency rule: **one concept, one name, everywhere**. General code naming (variables, files, casing) is [`nomenclature.md`](nomenclature.md); this rule owns the database.
3
+ Fixed conventions for relational schemas in **every** Nurix project — what earns a table, where a fact lives once it does, the key vocabulary, foreign-key doctrine, naming, and column types. A cross-project consistency rule: **one concept, one name, everywhere**. General code naming (variables, files, casing) is [`nomenclature.md`](nomenclature.md); this rule owns the database.
4
4
 
5
5
  Written for SQLite / Cloudflare D1 + Drizzle (the concrete types below). The names and key vocabulary are engine-agnostic — a Postgres project keeps the same column names and semantics with native types (`uuid`, `timestamptz`, `boolean`).
6
6
 
@@ -10,29 +10,64 @@ A table is the most expensive home for data — a migration to change its shape,
10
10
 
11
11
  | A table is earned by… | Lacking all of these, the data belongs in… |
12
12
  | -------------------------------------------------------------------------- | ------------------------------------------------------- |
13
- | identity minted at **runtime** | — |
14
- | variation **per tenant / user** | — |
13
+ | identity minted at **runtime** — a session, an upload | — |
14
+ | variation **per tenant / user** — a workspace's theme | — |
15
15
  | **independent addressing** — queried, indexed, or FK'd in isolation | a **JSON column** on its parent row |
16
16
  | **concurrency-safe** writes — racing writers must not clobber a shared blob | — |
17
17
  | a **DB-enforced invariant** — immutability trigger, unique, atomic boundary | — |
18
- | dev-authored, published on deploy, identical for every tenant | **config / a constants file**, validated at build |
19
- | exactly recomputable from other rows | a **cache** (KV / edge), recomputed on demand |
20
- | process state that is dead once the request completes | a **log or workflow engine**, never the domain DB |
21
- | owned by another system-of-record | a **reference column** — store the id, not the data |
18
+ | dev-authored, published on deploy, identical for every tenant — plan tiers | **config / a constants file**, validated at build |
19
+ | exactly recomputable from other rows — a tenant's usage total | a **cache** (KV / edge), recomputed on demand |
20
+ | process state dead once the request completes — a retry count | a **log or workflow engine**, never the domain DB |
21
+ | owned by another system-of-record — the payment processor's invoice | a **reference column** — store the id, not the data |
22
22
 
23
23
  Each right-column route is one test: process state — would a product feature break if this vanished the instant the request finished?; derived output — exactly recomputable from other rows + code?; un-addressed detail — is the row ever `SELECT`ed / `JOIN`ed / FK'd alone?; another system's record — does a specialized service (chat, telemetry, object store) already own it?
24
24
 
25
- - **Static vocabulary or a fixed graph → config, not a table.** Surface it in code as a constant/type; reifying it into tables plus a seed makes the file and the tables a two-copy sync problem. The refined boundary is § Vocabulary / lookup tables.
25
+ - **Static vocabulary or a fixed graph → config, not a table** — HTTP status labels, a fixed onboarding funnel. Surface it in code as a constant/type; reifying it into tables plus a seed makes the file and the tables a two-copy sync problem. The refined boundary is § Vocabulary / lookup tables.
26
26
  - **History → one immutable log + a mutable head.** Never shatter versioning into per-entity `*_revisions` / `*_revision_sections` tables — snapshot the coherent set into one append-only versions table; current state is a projection of the newest entry. A table that is just "old rows of another table" collapses into the snapshot.
27
27
  - **A surviving table names the invariant it buys** — content-addressed immutable rows buy reproducibility, a monotonic sequence buys ordering/sync, an atomic write boundary buys multi-row atomicity. Drop the requirement and the table drops with it.
28
28
 
29
29
  Table count tracks **requirements, not domain richness** — a rich domain is not entitled to a table per noun.
30
30
 
31
+ ## State, rules, and history
32
+
33
+ **One fact has one home, chosen by what kind of fact it is.** Confusing these produces the most common redundant table in a schema — a queue whose rows restate state the entity already carries.
34
+
35
+ | The fact | Home | Example |
36
+ | --- | --- | --- |
37
+ | What the entity **is now** | a **column on the entity's own row** | `documents.state = 'blocked'` |
38
+ | What may be **done** about that state | **code** — the transition map, the UI's affordances | which inputs unblock a document |
39
+ | What **was done**, and by whom | the **append-only audit log** | an `audit_log` row: `document.override_applied` |
40
+ | What a consumer must **replay in order** | the **sequence log** | an `app_events` row keyed `(app_id, sequence)` |
41
+
42
+ - **State becomes a column the moment anything selects on it.** `WHERE state = 'blocked'` takes an index; `WHERE json_extract(metadata, '$.state') = 'blocked'` scans the table and is invisible to every schema check. JSON keeps only what § What earns a table leaves it — detail nothing addresses alone.
43
+ - **A pending-work table mirroring row state is the anti-pattern** — `*_actions`, `*_inbox`, `*_flags`. The tell: resolving one of its rows also updates the entity, so two rows answer one question and are free to disagree. The *open question* is a column beside the state (at most one per row ⇒ a column, § A junction spells many-to-many); the *answer given* is an audit-log row.
44
+ - **Legal transitions live in code, never as rows** — a `status_transitions` table is a state machine the database cannot enforce and the code must re-implement anyway to use.
45
+
46
+ **The two logs are not substitutes**, and no one table serves both:
47
+
48
+ | | Sequence log | Audit log |
49
+ | --- | --- | --- |
50
+ | Answers | what happened here, **in order** | who did what, **and whether it succeeded** |
51
+ | Ordinal | a **gapless per-entity `sequence`**, allocated from a counter | none — nothing replays it |
52
+ | Scope | one entity, always | any — **including none**: a sign-up predates every tenant |
53
+ | Records | only what **happened** | **attempts too** — `denied`, `failure` |
54
+ | Read by | cursors replaying forward | investigations, at any age |
55
+
56
+ The bottom two rows are why neither can hold the other's rows: an event at a scope with no entity has no sequence to take, and a refused attempt in a replay stream would have consumers apply something that never happened.
57
+
58
+ ### The audit log's own shape
59
+
60
+ **An audit log outlives every row it names, so it stores what it needs to stay readable without them.** Both consequences are missing from most first drafts:
61
+
62
+ - **Denormalize the display at write time** — `actor_label`, `target_label` beside `actor_id` / `target_id`. Ids alone leave the log intact but unreadable once the person or record is deleted, which is the one thing it exists to survive.
63
+ - **Record the attempt, not the success** — `outcome` (`success | failure | denied`) plus a `reason`. Refused sign-ins and permission denials are most of what anyone opens an audit log for; without the column they cannot be written at all.
64
+ - **Keep the columns product-neutral** — `actor_*` · `action` · `outcome` · `target_*` · `scope_path` · `channel` · `source_ip` · `correlation_id` · `changes_json` serve any domain. Per-level tenancy columns tie the log to today's model; a materialized `scope_path` (`workspace:abc/app:def`) makes "everything under this tenant" one prefix scan and survives a new level appearing — at the price of enforcing the hierarchy in code rather than a `CHECK`.
65
+
31
66
  ## Scope, not parallel tables
32
67
 
33
68
  One entity that exists at several scopes is **one table with a nullable scope column** — never one table per scope, and never one table shared across entity families.
34
69
 
35
- - **A table shared by several families carries the union of their columns** — almost nothing can be `NOT NULL`, the payload lands in JSON beyond every constraint, index, and FK, and a new family widens a table other features already depend on. One table per family.
70
+ - **A table shared by several families carries the union of their columns** — one `settings` table for users, apps, and workspaces. Almost nothing can be `NOT NULL`, the payload lands in JSON beyond every constraint, index, and FK, and a new family widens a table other features already depend on. One table per family.
36
71
  - **Two tables declared to have "the same columns" are two schemas maintained by hand** — the drift is silent, surfacing as an incomplete copy, never as an error. Collapse them and discriminate by column.
37
72
  - **The scope column is the discriminator** (`app_id IS NULL` = parent scope, set = child scope). Copying a scope is then `INSERT … SELECT` over one table with one column rewritten, so source and target cannot disagree about shape.
38
73
  - **A nullable owner column is how a row exists before its owner does** — a pending enrolment or unclaimed record is creatable before the owner is known, which is what lets a `pending_*` / `*_requests` table collapse into the real one.
@@ -49,7 +84,7 @@ One entity that exists at several scopes is **one table with a nullable scope co
49
84
 
50
85
  Three column roles are fixed across every table and every project:
51
86
 
52
- - **`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.
87
+ - **`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.
53
88
  - **`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 rows between scopes). ❌ Never rename this role `handle`, `key`, `code`, or `name` in another project.
54
89
  - **`label`** — the **human display name** — ❌ never `name`, `title`, or `display_name`.
55
90
 
@@ -118,6 +153,6 @@ CREATE UNIQUE INDEX <t>_child_slug_unique ON <t> (child_id, slug) WHERE child_
118
153
 
119
154
  ## Vocabulary / lookup tables
120
155
 
121
- A controlled vocabulary earns a **table** only when a user row FKs into it and reads its `label` / `description` at runtime; static dev-authored config nobody references by FK stays **config** (§ What earns a table) — enumerable is not tabular, and a `type`-style column validated against the constants file beats an FK to a seeded mirror. An earned table has a fixed shape: `id` (UUID) · `slug` (UNIQUE) · `label` · optional `description` · `status` · timestamps.
156
+ A controlled vocabulary earns a **table** only when a user row FKs into it and reads its `label` / `description` at runtime — a service catalog rendered in a picker; static dev-authored config nobody references by FK stays **config** (§ What earns a table) — a `doc_type` value list. Enumerable is not tabular, and a `type`-style column validated against the constants file beats an FK to a seeded mirror. An earned table has a fixed shape: `id` (UUID) · `slug` (UNIQUE) · `label` · optional `description` · `status` · timestamps.
122
157
 
123
158
  - **Seeds are generated, never hand-written** — the source of truth is a CSV or a shared package; a generator emits the `*.seed.sql` with UUIDv5 ids and an idempotent `ON CONFLICT(slug)` upsert that preserves live counts / admin-tuned columns. ❌ Hand-editing seed SQL — regenerate it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.19.53",
3
+ "version": "0.19.54",
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": "b90b7910764f"
45
+ "etnaFingerprint": "70b47e452344"
46
46
  }