@nurix/etna 0.19.89 → 0.19.91

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.
@@ -0,0 +1,68 @@
1
+ // harness/code/hooks/lib/repo.mjs — the shared floor every hook module stands on: safe reads,
2
+ // path normalization, git, session ids, and the one definition of "source file". Each of these
3
+ // existed as 2–5 diverging private copies before being hoisted here.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { spawnSync } from "node:child_process";
8
+
9
+ export function readSafe(file) {
10
+ try {
11
+ return fs.readFileSync(file, "utf8");
12
+ } catch (e) {
13
+ return "";
14
+ }
15
+ }
16
+
17
+ // Repo-relative /-separated path, or null when the target lies outside the project.
18
+ export function relIfInside(projectDir, filePath) {
19
+ if (!filePath) return null;
20
+ const rel = path.relative(projectDir, path.resolve(projectDir, filePath));
21
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
22
+ return rel.split(path.sep).join("/");
23
+ }
24
+
25
+ export function git(cwd, args) {
26
+ const res = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 5000 });
27
+ return res.status === 0 ? (res.stdout || "").trim() : null;
28
+ }
29
+
30
+ export const today = () => new Date().toISOString().slice(0, 10);
31
+
32
+ export function sid8(sessionId) {
33
+ return String(sessionId || "unknown").replace(/[^a-zA-Z0-9-]/g, "").slice(0, 8) || "unknown";
34
+ }
35
+
36
+ // One definition of "source file" — the plan gate and the drift detector must agree on it.
37
+ export const SOURCE_EXT = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|css|scss|vue|svelte)$/;
38
+
39
+ // Space-free backticked tokens in a markdown chunk, `./` prefix stripped — the shared grammar
40
+ // for path-carrying prose (protected lists, plan scopes).
41
+ export function backtickTokens(text) {
42
+ const out = [];
43
+ for (const m of String(text || "").matchAll(/`([^`\n]+)`/g)) {
44
+ const candidate = m[1].trim().replace(/^\.\//, "");
45
+ if (candidate && !candidate.includes(" ")) out.push(candidate);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ // The repo a Bash command acts on: an explicit `cd <dir>` in the command wins, then the
51
+ // payload's cwd, then the session's home project — a session may operate across repos
52
+ // (forge + consumer), and gating a commit against the wrong repo blocks or passes falsely.
53
+ // A candidate is normalized to its git toplevel so a subdirectory cwd still resolves to the
54
+ // repo root. Claim and release both route here — two private copies of this rule once
55
+ // disagreed, and the divergence leaked stale claims.
56
+ export function commandRoot(projectDir, payload, command) {
57
+ const cdMatch = /(?:^|\n|&&|;)\s*cd\s+"?([^"\s;&]+)"?/.exec(command || "");
58
+ for (const candidate of [cdMatch && cdMatch[1], payload && payload.cwd]) {
59
+ if (!candidate || !path.isAbsolute(candidate)) continue;
60
+ try {
61
+ if (!fs.statSync(candidate).isDirectory()) continue;
62
+ } catch (e) {
63
+ continue;
64
+ }
65
+ return git(candidate, ["rev-parse", "--show-toplevel"]) || candidate;
66
+ }
67
+ return projectDir;
68
+ }
@@ -1,6 +1,6 @@
1
1
  // harness/code/hooks/lib/session-log.mjs — the per-session gate log: enforcement state, not a record.
2
2
  // It lives in .claude/state/sessions/ (machine-local, self-ignored) because its load-bearing role
3
- // is synchronous: machinery appends `denied` and `hook-crash` events, the session appends
3
+ // is synchronous: machinery appends `denied` events, the session appends
4
4
  // `resolved` lines, and the walls read PAIRS — a `denied` with no later `resolved` for the same
5
5
  // gate·target is an open failure, and silence about it is what the Stop and commit walls refuse.
6
6
  // Within a session resolution appends rather than deletes (the retry story is what the walls
@@ -10,10 +10,7 @@
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import { stateDir } from "./state.mjs";
13
-
14
- function sid8(sessionId) {
15
- return String(sessionId || "unknown").replace(/[^a-zA-Z0-9-]/g, "").slice(0, 8) || "unknown";
16
- }
13
+ import { sid8 } from "./repo.mjs";
17
14
 
18
15
  export function sessionLogPath(projectDir, sessionId) {
19
16
  const dir = path.join(stateDir(projectDir), "sessions");
@@ -5,7 +5,10 @@
5
5
  // - note: {YYYY-MM-DD} · {area} · {context} → lands-in: {owning doc | backlog | memory} | sid=…
6
6
  // - needs-session: {YYYY-MM-DD} · {what could not be grounded} → main session resolves | refs={ts}
7
7
  // - waived: {YYYY-MM-DD} · {what is deferred} · reason: {why} → expires={YYYY-MM-DD}
8
- // Unresolved = auto + note + needs-session. waived: rows are resolved history until expires= passes.
8
+ // Unresolved = auto + note + needs-session + EXPIRED waived. A `waived:` row is resolved history
9
+ // only while its expires= date is in the future; past it, the deferral is over and the wall returns.
10
+ // Rows are also re-verified against the repo before the walls read them (awareness.mjs's sweep),
11
+ // so a row whose condition died is deleted rather than waiting for its date.
9
12
 
10
13
  import fs from "node:fs";
11
14
  import path from "node:path";
@@ -14,7 +17,7 @@ export const QUEUE_PATH = ["docs", "operations", "bookkeeping.md"];
14
17
 
15
18
  export const QUEUE_HEADER = `# Bookkeeping
16
19
 
17
- The shared work queue keeping documentation and code in sync — a queue, never an archive. Rows are appended mechanically (auto:) and by working sessions (note:), drained by the bookkeeper agent, and walled at commit: a commit is possible only when every row is either **resolved, which deletes it**, or converted to \`waived:\`. A drained row leaves no trace here; what it closed is recorded in the doc it corrected, and git history holds the row itself. Row grammar and lifecycle: the \`documentation\` skill's \`templates/bookkeeping.md\`. A \`waived:\` row carries \`expires=\` and is PR-reviewable history.
20
+ The shared work queue keeping documentation and code in sync — a queue, never an archive. Rows are appended mechanically (auto:) and by working sessions (note:), drained by the bookkeeper agent, and walled at commit: a commit is possible only when every row is either **resolved, which deletes it**, or converted to \`waived:\`. A drained row leaves no trace here; what it closed is recorded in the doc it corrected, and git history holds the row itself. Row grammar and lifecycle: the \`documentation\` skill's \`templates/bookkeeping.md\`.\n\n**Every row is a cache of a condition, not a record of an event.** A session-start sweep re-tests each row against the repo and deletes the ones whose condition has died — \`waived:\` rows included, because a waiver defers a finding and never makes one immortal. What the sweep cannot mechanically re-test ages out instead: a \`waived:\` row carries \`expires=\`, is PR-reviewable history until that date, and counts as **unresolved** past it — which returns the wall and re-summons the bookkeeper to re-judge whether the reason still holds.
18
21
  `;
19
22
 
20
23
  export function queueFile(projectDir) {
@@ -113,15 +116,30 @@ export function readQueue(projectDir) {
113
116
  }
114
117
 
115
118
  // Counts by kind; `unresolved` is the number the walls act on.
116
- export function countRows(rows) {
117
- const c = { auto: 0, note: 0, needsSession: 0, waived: 0 };
119
+ //
120
+ // AN EXPIRED WAIVER IS AN UNRESOLVED ROW. A waiver is a time-boxed deferral, so `expires=` is the
121
+ // whole difference between deferring a finding and burying it — and until this counted, the date
122
+ // was decorative: nothing but the dead-glob allowlist ever read it, so an expired waiver passed
123
+ // every wall forever. Counting it here is what makes the deferral end, and what re-summons the
124
+ // bookkeeper, whose own instructions already say to re-check a waiver's reason and demote a dead
125
+ // one to `needs-session:`. The reviewer existed; this is the trigger it never had.
126
+ //
127
+ // A waiver with NO `expires=` is malformed and counts as expired — the grammar requires the date,
128
+ // and treating an absent one as "never expires" would make the omission the cheapest way to write
129
+ // a permanent waiver.
130
+ export function countRows(rows, today = new Date().toISOString().slice(0, 10)) {
131
+ const c = { auto: 0, note: 0, needsSession: 0, waived: 0, expiredWaived: 0 };
118
132
  for (const row of rows) {
119
133
  if (row.kind === "auto") c.auto += 1;
120
134
  else if (row.kind === "note") c.note += 1;
121
135
  else if (row.kind === "needs-session") c.needsSession += 1;
122
- else if (row.kind === "waived") c.waived += 1;
136
+ else if (row.kind === "waived") {
137
+ c.waived += 1;
138
+ const expires = (/expires=(\d{4}-\d{2}-\d{2})/.exec(row.body) || [])[1];
139
+ if (!expires || expires < today) c.expiredWaived += 1;
140
+ }
123
141
  }
124
- c.unresolved = c.auto + c.note + c.needsSession;
142
+ c.unresolved = c.auto + c.note + c.needsSession + c.expiredWaived;
125
143
  return c;
126
144
  }
127
145
 
@@ -201,18 +219,26 @@ export function resolveRows(projectDir, predicate) {
201
219
  return res.result;
202
220
  }
203
221
 
222
+ export function waiverExpiry(row) {
223
+ return (/expires=(\d{4}-\d{2}-\d{2})/.exec(row.body) || [])[1] || null;
224
+ }
225
+
204
226
  // Unexpired waived rows for a finding kind (the dead-glob allowlist CI honors).
205
- // A waived row covers a glob when its body names it verbatim.
206
227
  export function unexpiredWaivers(rows, kind, today) {
207
228
  return rows.filter((row) => {
208
229
  if (row.kind !== "waived") return false;
209
230
  if (kind && !row.body.includes(kind)) return false;
210
- const expires = (/expires=(\d{4}-\d{2}-\d{2})/.exec(row.body) || [])[1];
211
- if (!expires) return false;
212
- return expires >= today;
231
+ const expires = waiverExpiry(row);
232
+ return !!expires && expires >= today;
213
233
  });
214
234
  }
215
235
 
236
+ // True when an unexpired waiver of `kind` names the finding's subject. The subject is matched
237
+ // BACKTICKED — a bare substring match let a waiver for `src/dead1.ts` silently cover `src/dead`.
238
+ export function waiverCovers(rows, kind, subject, today) {
239
+ return unexpiredWaivers(rows, kind, today).some((w) => w.body.includes(`\`${subject}\``));
240
+ }
241
+
216
242
  // Ensure .claude/state/ exists and is self-ignoring (a `*` .gitignore inside it),
217
243
  // so consumers never see machine-local state as untracked noise. Returns the dir.
218
244
  export function stateDir(projectDir) {
@@ -15,4 +15,4 @@ The shared work queue keeping documentation and code in sync — a queue, never
15
15
  - waived: YYYY-MM-DD · {what is deferred} · reason: {why} → expires=YYYY-MM-DD
16
16
  ```
17
17
 
18
- Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `boundary-scan-suggested` · `doc-placement`.
18
+ Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `package-rejected` · `frozen-record-edited`.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "harness": "delivery",
3
3
  "name": "delivery",
4
- "version": "0.2.18",
4
+ "version": "0.2.19",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "a11ca1e20bd4"
11
+ "digest": "7358b638bff2"
12
12
  }
@@ -15,4 +15,4 @@ The shared work queue keeping documentation and code in sync — a queue, never
15
15
  - waived: YYYY-MM-DD · {what is deferred} · reason: {why} → expires=YYYY-MM-DD
16
16
  ```
17
17
 
18
- Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `boundary-scan-suggested` · `doc-placement`.
18
+ Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `package-rejected` · `frozen-record-edited`.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "harness": "etna",
3
3
  "name": "etna",
4
- "version": "1.5.7",
4
+ "version": "1.5.8",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "5843b3511178"
11
+ "digest": "f2edee92fac2"
12
12
  }
@@ -15,4 +15,4 @@ The shared work queue keeping documentation and code in sync — a queue, never
15
15
  - waived: YYYY-MM-DD · {what is deferred} · reason: {why} → expires=YYYY-MM-DD
16
16
  ```
17
17
 
18
- Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `boundary-scan-suggested` · `doc-placement`.
18
+ Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `package-rejected` · `frozen-record-edited`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.19.89",
3
+ "version": "0.19.91",
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": "f3094aa08430"
45
+ "etnaFingerprint": "b52694cf6295"
46
46
  }