@nurix/etna 0.19.69 → 0.19.70

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": "c39e1157ed11",
3
3
  "harnesses": {
4
4
  "code": {
5
5
  "plugin": "code",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "code",
3
3
  "name": "code",
4
- "version": "3.8.2",
4
+ "version": "3.8.4",
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": "cfd0df4274c4"
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
@@ -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
 
@@ -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
  | --- | --- |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "harness": "delivery",
3
3
  "name": "delivery",
4
- "version": "0.2.6",
4
+ "version": "0.2.8",
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": "bfd105b1ff3c"
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
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "harness": "etna",
3
3
  "name": "etna",
4
- "version": "1.4.5",
4
+ "version": "1.4.7",
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": "593183c2c83c"
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.70",
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": "c39e1157ed11"
46
46
  }