@nurix/etna 0.19.69 → 0.19.71

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": "67265cb7e0bf",
2
+ "fingerprint": "6492994f8d44",
3
3
  "harnesses": {
4
4
  "code": {
5
5
  "plugin": "code",
@@ -59,7 +59,7 @@ The ownership source of truth is the **union of every feature doc's `owns:` fron
59
59
  For each `owns:` glob across the feature docs:
60
60
 
61
61
  - Verify the glob matches existing files. Report globs matching nothing, deleted paths, and orphaned docs.
62
- - Flag **equal-specificity overlaps** — two feature docs whose `owns:` globs claim the same paths at the same specificity (an ambiguous owner). Surface as a question.
62
+ - Flag **equal-specificity overlaps** — two feature docs whose `owns:` globs claim the same paths at the same specificity (an ambiguous owner). `node .claude/hooks/check-ownership.mjs` reports these mechanically alongside dead globs (`overlaps()` in `lib/ownership.mjs`); a glob at strictly higher specificity carving out of a broader `**` is the designed layering and is never flagged. Surface a real overlap as a question — narrowing someone's glob is an author's call, not remediation.
63
63
  - **Stamp `type:` where missing.** Every doc carries a `type:` genre key (enum = the `documentation` skill's template names); docs predating the key are stamped mechanically — the genre is readable from placement and shape (a `docs/features/` doc is `type: feature`, an AD is `type: architecture-decision`). Stamping is remediation, not a question; an ambiguous genre is backlogged instead.
64
64
 
65
65
  For each code symbol or path a doc explicitly **names** (function/export/route/type):
@@ -57,7 +57,7 @@ The ownership source of truth is the **union of every feature doc's `owns:` fron
57
57
  For each `owns:` glob across the feature docs:
58
58
 
59
59
  - Verify the glob matches existing files. Report globs matching nothing, deleted paths, and orphaned docs.
60
- - Flag **equal-specificity overlaps** — two feature docs whose `owns:` globs claim the same paths at the same specificity (an ambiguous owner). Surface as a question.
60
+ - Flag **equal-specificity overlaps** — two feature docs whose `owns:` globs claim the same paths at the same specificity (an ambiguous owner). `node .claude/hooks/check-ownership.mjs` reports these mechanically alongside dead globs (`overlaps()` in `lib/ownership.mjs`); a glob at strictly higher specificity carving out of a broader `**` is the designed layering and is never flagged. Surface a real overlap as a question — narrowing someone's glob is an author's call, not remediation.
61
61
  - **Stamp `type:` where missing.** Every doc carries a `type:` genre key (enum = the `documentation` skill's template names); docs predating the key are stamped mechanically — the genre is readable from placement and shape (a `docs/features/` doc is `type: feature`, an AD is `type: architecture-decision`). Stamping is remediation, not a question; an ambiguous genre is backlogged instead.
62
62
 
63
63
  For each code symbol or path a doc explicitly **names** (function/export/route/type):
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "code",
3
3
  "name": "code",
4
- "version": "3.8.2",
4
+ "version": "3.9.1",
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": "39fd82e32002"
18
+ "digest": "bc889c51aa02"
19
19
  }
@@ -186,6 +186,13 @@ function driftBanner(projectDir) {
186
186
  }
187
187
 
188
188
  function sessionStart(projectDir) {
189
+ // Stamp the session's start so gitless repos still have a "modified since" horizon for the
190
+ // outcome detector's fallback scan (dirtyPaths). Overwritten per session; best-effort.
191
+ try {
192
+ fs.writeFileSync(path.join(stateDir(projectDir), "session-started"), new Date().toISOString() + "\n");
193
+ } catch (e) {
194
+ /* the stamp is a convenience for the fallback; git repos never need it */
195
+ }
189
196
  const lines = [];
190
197
  const pm = detectPackageManager(projectDir);
191
198
  if (pm) lines.push(`Package manager: ${pm}`);
@@ -366,7 +373,7 @@ function detectRenames(projectDir, command, meta, rows) {
366
373
  // Fails open: no git, or a git error, reads as an empty list.
367
374
  function dirtyPaths(projectDir) {
368
375
  const res = spawnSync("git", ["status", "--porcelain"], { cwd: projectDir, encoding: "utf8", timeout: 5000 });
369
- if (res.status !== 0) return [];
376
+ if (res.status !== 0) return mtimeFallbackPaths(projectDir);
370
377
  return (res.stdout || "")
371
378
  .split("\n")
372
379
  .filter(Boolean)
@@ -377,6 +384,44 @@ function dirtyPaths(projectDir) {
377
384
  .map((p) => p.replace(/^"|"$/g, ""));
378
385
  }
379
386
 
387
+ // No git ⇒ no porcelain, and the outcome detector went blind (a real run edited a frozen plan
388
+ // via `sed -i` in a gitless repo, unseen). Fall back to "modified since this session started":
389
+ // the SessionStart stamp bounds the scan, and the scan covers exactly the two surfaces the
390
+ // detector reads — package manifests and docs/history records. No stamp ⇒ empty, as before.
391
+ function mtimeFallbackPaths(projectDir) {
392
+ let since = 0;
393
+ try {
394
+ since = fs.statSync(path.join(stateDir(projectDir), "session-started")).mtimeMs;
395
+ } catch (e) {
396
+ return [];
397
+ }
398
+ const out = [];
399
+ const walk = (dirRel, depth, wanted) => {
400
+ let entries;
401
+ try {
402
+ entries = fs.readdirSync(path.join(projectDir, dirRel), { withFileTypes: true });
403
+ } catch (e) {
404
+ return;
405
+ }
406
+ for (const en of entries) {
407
+ if (en.name === "node_modules" || en.name.startsWith(".")) continue;
408
+ const rel = dirRel ? `${dirRel}/${en.name}` : en.name;
409
+ if (en.isDirectory()) {
410
+ if (depth > 0) walk(rel, depth - 1, wanted);
411
+ } else if (wanted(en.name)) {
412
+ try {
413
+ if (fs.statSync(path.join(projectDir, rel)).mtimeMs >= since) out.push(rel);
414
+ } catch (e) {
415
+ /* raced away — not dirty */
416
+ }
417
+ }
418
+ }
419
+ };
420
+ walk("", 3, (name) => name === "package.json");
421
+ walk("docs/history", 4, (name) => name.endsWith(".md"));
422
+ return out;
423
+ }
424
+
380
425
  // OUTCOME RECOMPUTATION — the gates deny wrong actions, but an action can be routed around by
381
426
  // any tool that writes bytes (a manifest written by a script, a frozen record touched by sed).
382
427
  // State cannot be routed around: recompute the two outcomes the gates guard and raise what
@@ -1,22 +1,57 @@
1
- // harness/code/hooks/check-ownership.mjs — CI backstop: every owns: glob must match at least one
2
- // file, unless an unexpired `waived:` dead-glob row in docs/operations/bookkeeping.md covers it
3
- // (the waiver names the glob verbatim). Exit 1 on any unwaived dead glob.
1
+ // harness/code/hooks/check-ownership.mjs — CI backstop for docs/features/ ownership. Two checks,
2
+ // one walk of the tree:
3
+ //
4
+ // 1. dead-glob — every owns: glob matches at least one file
5
+ // 2. ownership-overlap — no two feature docs claim a common file at EQUAL specificity, which
6
+ // leaves "most specific wins" unable to pick an owner
7
+ //
8
+ // Either is silenced by an unexpired `waived:` row in docs/operations/bookkeeping.md naming its
9
+ // kind plus the offending glob (dead-glob) or the shared path (ownership-overlap) verbatim.
10
+ // Exit 1 on anything unwaived.
11
+ //
12
+ // A glob at strictly HIGHER specificity carving a sub-path out of a broader `**` is the designed
13
+ // layering, not a conflict — only an exact tie is reported.
4
14
 
5
- import { deadGlobs } from "./lib/ownership.mjs";
15
+ import { deadGlobs, listRepoFiles, overlaps } from "./lib/ownership.mjs";
6
16
  import { readQueue, unexpiredWaivers } from "./lib/state.mjs";
7
17
 
8
18
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
9
19
  const today = new Date().toISOString().slice(0, 10);
10
20
 
11
- const dead = deadGlobs(projectDir);
12
- const waivers = unexpiredWaivers(readQueue(projectDir), "dead-glob", today);
13
- const unwaived = dead.filter((d) => !waivers.some((w) => w.body.includes(d.glob)));
21
+ const files = listRepoFiles(projectDir);
22
+ const queue = readQueue(projectDir);
14
23
 
15
- if (unwaived.length === 0) {
16
- console.log(`check-ownership: OK (${dead.length} dead glob(s), all waived or none)`);
24
+ const dead = deadGlobs(projectDir, files);
25
+ const deadWaivers = unexpiredWaivers(queue, "dead-glob", today);
26
+ const unwaivedDead = dead.filter((d) => !deadWaivers.some((w) => w.body.includes(d.glob)));
27
+
28
+ const conflicts = overlaps(projectDir, files);
29
+ const overlapWaivers = unexpiredWaivers(queue, "ownership-overlap", today);
30
+ const unwaivedOverlaps = conflicts.filter((c) => !overlapWaivers.some((w) => w.body.includes(c.example)));
31
+
32
+ if (unwaivedDead.length === 0 && unwaivedOverlaps.length === 0) {
33
+ console.log(
34
+ `check-ownership: OK (${dead.length} dead glob(s), ${conflicts.length} equal-specificity overlap(s), all waived or none)`,
35
+ );
17
36
  process.exit(0);
18
37
  }
19
- console.error(`check-ownership: ${unwaived.length} unwaived dead owns: glob(s):`);
20
- for (const d of unwaived) console.error(` ${d.glob} → ${d.doc}`);
21
- console.error("Fix the owning doc's owns: frontmatter, or add a `waived:` dead-glob row with reason + expires=.");
38
+
39
+ if (unwaivedDead.length > 0) {
40
+ console.error(`check-ownership: ${unwaivedDead.length} unwaived dead owns: glob(s):`);
41
+ for (const d of unwaivedDead) console.error(` ${d.glob} → ${d.doc}`);
42
+ console.error("Fix the owning doc's owns: frontmatter, or add a `waived:` dead-glob row with reason + expires=.");
43
+ }
44
+
45
+ if (unwaivedOverlaps.length > 0) {
46
+ console.error(`check-ownership: ${unwaivedOverlaps.length} unwaived equal-specificity overlap(s):`);
47
+ for (const c of unwaivedOverlaps) {
48
+ console.error(` ${c.example}`);
49
+ console.error(` ${c.a.glob} → ${c.a.doc}`);
50
+ console.error(` ${c.b.glob} → ${c.b.doc}`);
51
+ }
52
+ console.error(
53
+ "Narrow one doc's glob so a single owner wins, or add a `waived:` ownership-overlap row naming the shared path with reason + expires=.",
54
+ );
55
+ }
56
+
22
57
  process.exit(1);
@@ -12,7 +12,7 @@ import { readStdin, resolveProjectDir, frontmatter } from "./lib/ownership.mjs";
12
12
  import { readQueue, countRows, askedThisSession, recordAsked } from "./lib/state.mjs";
13
13
  import { frozenStatus } from "./lib/frozen.mjs";
14
14
  import { undischargedRows } from "./lib/ledger.mjs";
15
- import { appendEvent, unresolvedDenials, sessionLogPath } from "./lib/session-log.mjs";
15
+ import { appendEvent, unresolvedDenials, sessionLogPath, malformedEventLines } from "./lib/session-log.mjs";
16
16
  import { approvedPackages } from "./lib/approved.mjs";
17
17
 
18
18
  function readSafe(file) {
@@ -337,9 +337,61 @@ function bashGate(projectDir, command, sessionId) {
337
337
  );
338
338
  }
339
339
 
340
+ const shellWrite = bashWriteScreen(projectDir, command, sessionId);
341
+ if (shellWrite) return shellWrite;
342
+
340
343
  return packageGate(projectDir, command, sessionId);
341
344
  }
342
345
 
346
+ // A file write arriving by shell hits the same wall the Edit tool does. Only tokens in a
347
+ // WRITING position count — redirect targets, `tee`/`mv`/`cp` destinations, and any path on an
348
+ // in-place editor line (`sed -i`, `perl -i`) — so reading a frozen record into a pipe stays
349
+ // legal. A token that isn't an existing repo path falls through frozenStatus/protected as a
350
+ // no-op, which keeps sed expressions and flags harmless without shell-parsing them.
351
+ function bashWriteTargets(command) {
352
+ const targets = [];
353
+ let m;
354
+ const redirect = />{1,2}\s*([^\s;|&<>]+)/g;
355
+ while ((m = redirect.exec(command))) targets.push(m[1]);
356
+ const tee = /\btee\s+(?:-\S+\s+)*([^\s;|&<>]+)/g;
357
+ while ((m = tee.exec(command))) targets.push(m[1]);
358
+ const moveCopy = /\b(?:mv|cp)\s+(?:-\S+\s+)*\S+\s+([^\s;|&<>]+)/g;
359
+ while ((m = moveCopy.exec(command))) targets.push(m[1]);
360
+ if (/\b(?:sed|perl)\s+(?:-\S*\s+)*-\S*i/.test(command) || /\bsed\s+--in-place\b/.test(command)) {
361
+ for (const t of command.split(/[\s"']+/)) {
362
+ if (t && !t.startsWith("-") && /[/.]/.test(t)) targets.push(t);
363
+ }
364
+ }
365
+ return targets;
366
+ }
367
+
368
+ function bashWriteScreen(projectDir, command, sessionId) {
369
+ for (const target of bashWriteTargets(command)) {
370
+ const rel = relIfInside(projectDir, target);
371
+ if (!rel) continue;
372
+ const frozen = frozenStatus(projectDir, rel);
373
+ if (frozen.frozen) {
374
+ return deny(
375
+ projectDir,
376
+ sessionId,
377
+ "frozen-record",
378
+ rel,
379
+ `BLOCKED — frozen record. \`${rel}\` is committed history and is never edited (${frozen.reason}) — a shell write is still a write. A correction is a new dated entry that links back.`
380
+ );
381
+ }
382
+ if (protectedPaths(projectDir).includes(rel)) {
383
+ return deny(
384
+ projectDir,
385
+ sessionId,
386
+ "protected-file",
387
+ rel,
388
+ `BLOCKED — protected file. \`${rel}\` is on the protected list — a shell write is still a write.`
389
+ );
390
+ }
391
+ }
392
+ return null;
393
+ }
394
+
343
395
  // ---------------------------------------------------------------------------
344
396
  // Stop
345
397
  // ---------------------------------------------------------------------------
@@ -463,13 +515,21 @@ function stopGate(projectDir, payload) {
463
515
  const openDenials = unresolvedDenials(projectDir, payload.session_id);
464
516
  if (openDenials.length > 0) {
465
517
  const rel = path.relative(projectDir, sessionLogPath(projectDir, payload.session_id));
518
+ // A resolution that doesn't parse reads to the session as "my resolution didn't count"
519
+ // with no explanation — name the offending lines and the grammar instead of staying mute.
520
+ const bad = malformedEventLines(projectDir, payload.session_id);
521
+ const badNote =
522
+ bad.length > 0
523
+ ? ` NOTE: ${bad.length} line(s) look like events but don't parse — the grammar is \`- HH:MM:SS denied|resolved · gate · target[ · detail]\` (a resolved line missing its timestamp is invisible to the wall): ${bad.join(" | ")}.`
524
+ : "";
466
525
  return {
467
526
  decision: "block",
468
527
  reason:
469
528
  `${openDenials.length} gate denial(s) this session have no resolved line in ${rel}: ${openDenials.join("; ")}. ` +
470
529
  "For each, either retry the operation properly (a success appends `retried-ok` automatically), convert it to a waiver and append " +
471
530
  "`resolved · {gate} · {target} · waived → {ref}`, or record the failure honestly in the changelog entry and append " +
472
- "`resolved · {gate} · {target} · disclosed → {entry file}`. Never delete log lines — the log is a record, not a queue.",
531
+ "`resolved · {gate} · {target} · disclosed → {entry file}`. Never delete log lines — the log is a record, not a queue." +
532
+ badNote,
473
533
  };
474
534
  }
475
535
 
@@ -242,3 +242,60 @@ export function deadGlobs(projectDir, repoFiles) {
242
242
  }
243
243
  return dead;
244
244
  }
245
+
246
+ // Specificity of an owns: glob as a (literal, wildcard) segment tuple. The ownership rule is
247
+ // "most specific wins", so a glob at strictly higher specificity legitimately carves a sub-path
248
+ // out of a broader `**` — that is the designed layering, never a conflict. Only an exact tie
249
+ // leaves the owner ambiguous, which is what `overlaps()` reports.
250
+ export function specificity(glob) {
251
+ let literal = 0;
252
+ let wildcard = 0;
253
+ for (const segment of String(glob).split("/")) {
254
+ if (segment === "") continue;
255
+ if (segment.includes("*") || segment.includes("?")) wildcard++;
256
+ else literal++;
257
+ }
258
+ return { literal, wildcard };
259
+ }
260
+
261
+ // Two globs are equally specific when both tuple components match.
262
+ export function sameSpecificity(a, b) {
263
+ return a.literal === b.literal && a.wildcard === b.wildcard;
264
+ }
265
+
266
+ // Cross-doc equal-specificity ownership conflicts: two globs from DIFFERENT feature docs that
267
+ // match a common file at the same specificity, so "most specific wins" cannot pick an owner and
268
+ // the path is doubly owned. Returns [{ a: {doc, glob}, b: {doc, glob}, example }].
269
+ //
270
+ // This is the mechanical half of a rule the harness has always stated and never checked — the
271
+ // `documentation` skill's frontmatter reference calls an equal-specificity overlap "an ownership
272
+ // conflict to resolve", and the `maintenance` agent is told to flag one. Without this, both had
273
+ // to be enforced by eye.
274
+ export function overlaps(projectDir, repoFiles) {
275
+ const files = repoFiles || listRepoFiles(projectDir);
276
+ const owners = [];
277
+ for (const entry of scanFeatureDocs(projectDir)) {
278
+ if (entry.status === "deprecated") continue;
279
+ for (const glob of entry.owns) {
280
+ const re = globToRegExp(glob);
281
+ owners.push({ doc: entry.doc, glob, spec: specificity(glob), files: files.filter((f) => re.test(f)) });
282
+ }
283
+ }
284
+ return pairOverlaps(owners);
285
+ }
286
+
287
+ // The pure core of `overlaps()`, split out so tests can feed synthetic owners without a repo tree.
288
+ export function pairOverlaps(owners) {
289
+ const found = [];
290
+ for (let i = 0; i < owners.length; i++) {
291
+ for (let j = i + 1; j < owners.length; j++) {
292
+ const a = owners[i];
293
+ const b = owners[j];
294
+ if (a.doc === b.doc) continue;
295
+ if (!sameSpecificity(a.spec, b.spec)) continue;
296
+ const example = a.files.find((f) => b.files.includes(f));
297
+ if (example) found.push({ a: { doc: a.doc, glob: a.glob }, b: { doc: b.doc, glob: b.glob }, example });
298
+ }
299
+ }
300
+ return found;
301
+ }
@@ -73,3 +73,23 @@ export function unresolvedDenials(projectDir, sessionId) {
73
73
  }
74
74
  return [...open.keys()];
75
75
  }
76
+
77
+ // Lines that LOOK like events but don't parse — a resolution missing its timestamp token, a
78
+ // hand-typed variant of the grammar. The pair-matcher above skips them silently, which reads
79
+ // to the session as "my resolution didn't count" with no explanation (a real session burned
80
+ // its tail debugging exactly this). The walls surface these loudly instead.
81
+ export function malformedEventLines(projectDir, sessionId) {
82
+ let text = "";
83
+ try {
84
+ text = fs.readFileSync(sessionLogPath(projectDir, sessionId), "utf8");
85
+ } catch (e) {
86
+ return [];
87
+ }
88
+ const bad = [];
89
+ for (const line of text.split("\n")) {
90
+ if (!/^- .*\b(denied|resolved)\b\s*·/.test(line)) continue;
91
+ if (/^- \S+ (denied|resolved) · ([^·]+) · ([^·\n]+?)(?: · |$)/.test(line)) continue;
92
+ bad.push(line.trim());
93
+ }
94
+ return bad;
95
+ }
@@ -55,7 +55,7 @@ Test packages install at `latest` — outside the approved-packages pin set. Fak
55
55
  - **Wire the canonical command.** `"test"` in `package.json` runs the whole unit suite (`vitest run`); e2e under `"test:e2e"`. A repo where `pnpm test` / `npm test` doesn't run the suite is misconfigured — fix the wiring, **never** route around it.
56
56
  - **Unit and E2E trees stay separate.** Vitest/Jest suites in `tests/`; Playwright/Maestro suites in `e2e/` with their own config — **never** mixed into the unit runner's glob.
57
57
  - **No test doubles for what you own.** Prefer real instances — in-memory SQLite for drizzle, the actual Express app via `supertest` — over mocks; mock **only** true externals (network via `msw`, time via the runner's fake timers).
58
- - **Determinism.** No real network, no real time, no shared mutable state between tests; every test **must** pass alone and in any order.
58
+ - **Determinism.** No real network, no real time, no shared mutable state between tests; every test **must** pass alone, in any order, **and from a cold shell** — never against a hand-started dev server (Workers: `@cloudflare/vitest-pool-workers`; Express: `supertest` against the app instance).
59
59
 
60
60
  ## Discipline
61
61
 
@@ -15,7 +15,7 @@ The **`Gates:`** line is machine-read: the compliance hook greps exactly the for
15
15
  **Gates:** Boundary: pending · Test-plan: pending · Docs: pending
16
16
  **Spec:** [docs/features/{feature}.md](../../features/{feature}.md) <!-- one line per feature doc this plan implements against; a plan spanning three features names three -->
17
17
  **Started:** YYYY-MM-DD
18
- **Completed:** YYYY-MM-DD <!-- fill when status flips to Complete -->
18
+ **Completed:** YYYY-MM-DD <!-- fill in the SAME edit that flips Status to Complete: the flip freezes the file instantly, so a date left for a second edit is unreachable (the frozen-record gate rightly blocks it) -->
19
19
 
20
20
  ## Background
21
21
 
@@ -152,7 +152,7 @@ Runs only when the user triggers the verification gate (constraints — precondi
152
152
 
153
153
  1. **Write the checklist** — derive or update `docs/tests/{feature}.md` from the ledger and the LLD's promises (the `documentation` skill's `templates/behavioral-checklist.md`; one `**Assert**` per observable). Derived before the driving it is the script; derived after, it is merely a report with nothing to trace tests back to.
154
154
  2. **Drive the real surface** — browser automation for web UI (the `/test` runner skill where the repo wires it), the actual CLI binary, HTTP against the running worker. **A failing checklist assertion is never softened to make a run green** — record the defect, send it to the backlog. Where the runtime offers no browser automation, emit the browser-verification checklist for the user to run; their report is the rung's verdict and `Status: Complete` waits for it.
155
- 3. **Pin the durable behaviors** as suite tests per `testing.md`, naming each test's stratum (§ Bridge). A test is a permanent maintenance cost, bought deliberately:
155
+ 3. **Pin the durable behaviors** as suite tests per `testing.md`, naming each test's stratum (§ Bridge). **A pinned test must pass from a cold shell** — `pnpm test` with no dev server hand-started, no seeded state assumed beyond what the suite builds for itself. A test whose oracle is "fetch the server I happen to have running" is a driven check wearing a test's name: it goes red the moment the session ends (on Workers, `@cloudflare/vitest-pool-workers` is the self-contained harness; an Express app is driven via `supertest`, never a port). Before discharging, run the suite once from a clean shell — a suite that needs the session's environment discharges nothing. A test is a permanent maintenance cost, bought deliberately:
156
156
 
157
157
  | Earns a suite test | Verified by driving alone |
158
158
  | --- | --- |
@@ -59,7 +59,7 @@ The ownership source of truth is the **union of every feature doc's `owns:` fron
59
59
  For each `owns:` glob across the feature docs:
60
60
 
61
61
  - Verify the glob matches existing files. Report globs matching nothing, deleted paths, and orphaned docs.
62
- - Flag **equal-specificity overlaps** — two feature docs whose `owns:` globs claim the same paths at the same specificity (an ambiguous owner). Surface as a question.
62
+ - Flag **equal-specificity overlaps** — two feature docs whose `owns:` globs claim the same paths at the same specificity (an ambiguous owner). `node .claude/hooks/check-ownership.mjs` reports these mechanically alongside dead globs (`overlaps()` in `lib/ownership.mjs`); a glob at strictly higher specificity carving out of a broader `**` is the designed layering and is never flagged. Surface a real overlap as a question — narrowing someone's glob is an author's call, not remediation.
63
63
  - **Stamp `type:` where missing.** Every doc carries a `type:` genre key (enum = the `documentation` skill's template names); docs predating the key are stamped mechanically — the genre is readable from placement and shape (a `docs/features/` doc is `type: feature`, an AD is `type: architecture-decision`). Stamping is remediation, not a question; an ambiguous genre is backlogged instead.
64
64
 
65
65
  For each code symbol or path a doc explicitly **names** (function/export/route/type):
@@ -57,7 +57,7 @@ The ownership source of truth is the **union of every feature doc's `owns:` fron
57
57
  For each `owns:` glob across the feature docs:
58
58
 
59
59
  - Verify the glob matches existing files. Report globs matching nothing, deleted paths, and orphaned docs.
60
- - Flag **equal-specificity overlaps** — two feature docs whose `owns:` globs claim the same paths at the same specificity (an ambiguous owner). Surface as a question.
60
+ - Flag **equal-specificity overlaps** — two feature docs whose `owns:` globs claim the same paths at the same specificity (an ambiguous owner). `node .claude/hooks/check-ownership.mjs` reports these mechanically alongside dead globs (`overlaps()` in `lib/ownership.mjs`); a glob at strictly higher specificity carving out of a broader `**` is the designed layering and is never flagged. Surface a real overlap as a question — narrowing someone's glob is an author's call, not remediation.
61
61
  - **Stamp `type:` where missing.** Every doc carries a `type:` genre key (enum = the `documentation` skill's template names); docs predating the key are stamped mechanically — the genre is readable from placement and shape (a `docs/features/` doc is `type: feature`, an AD is `type: architecture-decision`). Stamping is remediation, not a question; an ambiguous genre is backlogged instead.
62
62
 
63
63
  For each code symbol or path a doc explicitly **names** (function/export/route/type):
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "harness": "delivery",
3
3
  "name": "delivery",
4
- "version": "0.2.6",
4
+ "version": "0.2.9",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "6687f84006a3"
11
+ "digest": "cebc16bda479"
12
12
  }
@@ -15,7 +15,7 @@ The **`Gates:`** line is machine-read: the compliance hook greps exactly the for
15
15
  **Gates:** Boundary: pending · Test-plan: pending · Docs: pending
16
16
  **Spec:** [docs/features/{feature}.md](../../features/{feature}.md) <!-- one line per feature doc this plan implements against; a plan spanning three features names three -->
17
17
  **Started:** YYYY-MM-DD
18
- **Completed:** YYYY-MM-DD <!-- fill when status flips to Complete -->
18
+ **Completed:** YYYY-MM-DD <!-- fill in the SAME edit that flips Status to Complete: the flip freezes the file instantly, so a date left for a second edit is unreachable (the frozen-record gate rightly blocks it) -->
19
19
 
20
20
  ## Background
21
21
 
@@ -59,7 +59,7 @@ The ownership source of truth is the **union of every feature doc's `owns:` fron
59
59
  For each `owns:` glob across the feature docs:
60
60
 
61
61
  - Verify the glob matches existing files. Report globs matching nothing, deleted paths, and orphaned docs.
62
- - Flag **equal-specificity overlaps** — two feature docs whose `owns:` globs claim the same paths at the same specificity (an ambiguous owner). Surface as a question.
62
+ - Flag **equal-specificity overlaps** — two feature docs whose `owns:` globs claim the same paths at the same specificity (an ambiguous owner). `node .claude/hooks/check-ownership.mjs` reports these mechanically alongside dead globs (`overlaps()` in `lib/ownership.mjs`); a glob at strictly higher specificity carving out of a broader `**` is the designed layering and is never flagged. Surface a real overlap as a question — narrowing someone's glob is an author's call, not remediation.
63
63
  - **Stamp `type:` where missing.** Every doc carries a `type:` genre key (enum = the `documentation` skill's template names); docs predating the key are stamped mechanically — the genre is readable from placement and shape (a `docs/features/` doc is `type: feature`, an AD is `type: architecture-decision`). Stamping is remediation, not a question; an ambiguous genre is backlogged instead.
64
64
 
65
65
  For each code symbol or path a doc explicitly **names** (function/export/route/type):
@@ -57,7 +57,7 @@ The ownership source of truth is the **union of every feature doc's `owns:` fron
57
57
  For each `owns:` glob across the feature docs:
58
58
 
59
59
  - Verify the glob matches existing files. Report globs matching nothing, deleted paths, and orphaned docs.
60
- - Flag **equal-specificity overlaps** — two feature docs whose `owns:` globs claim the same paths at the same specificity (an ambiguous owner). Surface as a question.
60
+ - Flag **equal-specificity overlaps** — two feature docs whose `owns:` globs claim the same paths at the same specificity (an ambiguous owner). `node .claude/hooks/check-ownership.mjs` reports these mechanically alongside dead globs (`overlaps()` in `lib/ownership.mjs`); a glob at strictly higher specificity carving out of a broader `**` is the designed layering and is never flagged. Surface a real overlap as a question — narrowing someone's glob is an author's call, not remediation.
61
61
  - **Stamp `type:` where missing.** Every doc carries a `type:` genre key (enum = the `documentation` skill's template names); docs predating the key are stamped mechanically — the genre is readable from placement and shape (a `docs/features/` doc is `type: feature`, an AD is `type: architecture-decision`). Stamping is remediation, not a question; an ambiguous genre is backlogged instead.
62
62
 
63
63
  For each code symbol or path a doc explicitly **names** (function/export/route/type):
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "harness": "etna",
3
3
  "name": "etna",
4
- "version": "1.4.5",
4
+ "version": "1.4.8",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "8713f233f073"
11
+ "digest": "e69567759f7b"
12
12
  }
@@ -55,7 +55,7 @@ Test packages install at `latest` — outside the approved-packages pin set. Fak
55
55
  - **Wire the canonical command.** `"test"` in `package.json` runs the whole unit suite (`vitest run`); e2e under `"test:e2e"`. A repo where `pnpm test` / `npm test` doesn't run the suite is misconfigured — fix the wiring, **never** route around it.
56
56
  - **Unit and E2E trees stay separate.** Vitest/Jest suites in `tests/`; Playwright/Maestro suites in `e2e/` with their own config — **never** mixed into the unit runner's glob.
57
57
  - **No test doubles for what you own.** Prefer real instances — in-memory SQLite for drizzle, the actual Express app via `supertest` — over mocks; mock **only** true externals (network via `msw`, time via the runner's fake timers).
58
- - **Determinism.** No real network, no real time, no shared mutable state between tests; every test **must** pass alone and in any order.
58
+ - **Determinism.** No real network, no real time, no shared mutable state between tests; every test **must** pass alone, in any order, **and from a cold shell** — never against a hand-started dev server (Workers: `@cloudflare/vitest-pool-workers`; Express: `supertest` against the app instance).
59
59
 
60
60
  ## Discipline
61
61
 
@@ -15,7 +15,7 @@ The **`Gates:`** line is machine-read: the compliance hook greps exactly the for
15
15
  **Gates:** Boundary: pending · Test-plan: pending · Docs: pending
16
16
  **Spec:** [docs/features/{feature}.md](../../features/{feature}.md) <!-- one line per feature doc this plan implements against; a plan spanning three features names three -->
17
17
  **Started:** YYYY-MM-DD
18
- **Completed:** YYYY-MM-DD <!-- fill when status flips to Complete -->
18
+ **Completed:** YYYY-MM-DD <!-- fill in the SAME edit that flips Status to Complete: the flip freezes the file instantly, so a date left for a second edit is unreachable (the frozen-record gate rightly blocks it) -->
19
19
 
20
20
  ## Background
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.19.69",
3
+ "version": "0.19.71",
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": "67265cb7e0bf"
45
+ "etnaFingerprint": "6492994f8d44"
46
46
  }