@nurix/etna 0.19.89 → 0.19.90

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": "f3094aa08430",
2
+ "fingerprint": "8d6f9b80d64d",
3
3
  "harnesses": {
4
4
  "code": {
5
5
  "plugin": "code",
@@ -139,7 +139,7 @@ Defaults — ❌ prohibitions hold unless the user explicitly asks otherwise;
139
139
  - ❌ **Branches & Worktrees [override]** – Never self-create git branches or worktrees; commit straight to the checked-out branch. This replaces any built-in "branch first on the default branch" default. Exception: when the user asks for a pull request, create the branch the PR requires before committing its changes.
140
140
  - ❌ **Protected Files** – Once per session, if `docs/operations/protected-files.md` exists, read it and never modify any path it lists; never modify `AGENTS.md` (harness-regenerated) or `protected-files.md` itself.
141
141
  - ✅ **Browser Automation** – If the runtime provides browser automation, use it freely; no need to ask first. If it doesn't — or the executing agent can't hold the browser connection (see Runtime Notes) — return a browser-verification checklist (URLs, actions, expected outcomes) for the orchestrating session or the user to run.
142
- - ✅ **Bookkeeping** — hooks silently append doc-debt rows to `docs/operations/bookkeeping.md`; the commit hook blocks while unresolved rows remain — dispatch the `bookkeeper` agent to drain the queue. The queue is a queue, never an archive: a drained row is **deleted**, so a fully drained file holds no rows at all.
142
+ - ✅ **Bookkeeping** — hooks silently append doc-debt rows to `docs/operations/bookkeeping.md`; the commit hook blocks while unresolved rows remain — dispatch the `bookkeeper` agent to drain the queue. The queue is a queue, never an archive: a drained row is **deleted**, so a fully drained file holds no rows at all. **Every row is a cache of a condition, not a record of an event** — the session-start sweep re-tests each row against the repo and deletes the ones whose condition has died, `waived:` rows included, so the file empties itself as the work lands. What the sweep cannot re-test ages out instead: **a `waived:` row is unresolved once `expires=` passes**, which returns the wall and re-summons the bookkeeper to re-judge the reason. A waiver defers a finding; it never makes one immortal.
143
143
  - ✅ **Cleanup removes the cause, not the record of it.** When the user asks for something to be deleted or cleaned up, the ask is for the underlying condition to be gone. Deleting the record while the condition survives is worse than doing nothing — it destroys the only evidence the debt exists. The order is fixed: **read what the record is masking** (a `waived:` row, an `UNBUILT:` marker, a `status:` flag, a lint suppression — each asserts something is fine; check whether that is still true), **fix the condition**, **then delete the record**. Reversing it backfires concretely: a deleted waiver's finding is re-appended by the detector as an `auto:` row, which *fails* the commit wall the `waived:` row passed.
144
144
  - ✅ **Delegation** — when to fan out, tiers, and trust are § Delegation.
145
145
  - ⚠️ **Plans** – Plans are created directly in `docs/history/plans/` (see Workflows). Once `Status: Complete` or `Status: Abandoned`, the file is frozen — never edited. Abandoning a plan whose code was committed first converts its undischarged Test Obligations into backlog items (the `testing` rule). If scope changes after completion, write a new dated plan that links back to the old one.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "code",
3
3
  "name": "code",
4
- "version": "3.10.12",
4
+ "version": "3.10.13",
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": "8b4f074b1311"
18
+ "digest": "e58c5f75136f"
19
19
  }
@@ -126,15 +126,117 @@ function openPlanLines(projectDir) {
126
126
  return lines;
127
127
  }
128
128
 
129
+ // --- the re-verification sweep ---------------------------------------------------------------
130
+ //
131
+ // THE QUEUE IS DERIVED, NOT ACCUMULATED. Every finding here is recomputable from the repo, so a row
132
+ // is a cache of a condition rather than a record of an event — and a cache nothing invalidates is
133
+ // an archive. Before any wall reads the queue, each row is re-tested against the repo as it is NOW,
134
+ // and a row whose condition has died is deleted.
135
+ //
136
+ // THIS RUNS OVER `waived:` ROWS TOO, WHICH IS THE POINT. A waiver defers a finding; it does not
137
+ // make the finding immortal. Without this, waiving was strictly WORSE than leaving a row alone —
138
+ // the one existing resolver filtered `kind === "auto"`, so waiving a self-clearing row opted it out
139
+ // of its own return path forever. The condition, not the row's kind, decides.
140
+ //
141
+ // A kind with no verifier here is not swept — it ages out through `expires=` instead. Add a
142
+ // verifier whenever a finding becomes mechanically checkable; silence is the only failure mode
143
+ // this file has, so a verifier that is unsure must return false and leave the row standing.
144
+ const ROW_VERIFIERS = {
145
+ // "<rel> lacks its file-path header comment" — dead once the header is there (or the file is gone).
146
+ "header-missing": (projectDir, body) => {
147
+ const m = /·\s+header-missing\s+·\s+(\S+)\s+lacks/.exec(body);
148
+ if (!m) return false;
149
+ const rel = m[1];
150
+ if (!fs.existsSync(path.join(projectDir, rel))) return true;
151
+ return hasHeaderComment(projectDir, rel);
152
+ },
153
+
154
+ // "owns: glob `<glob>` in <doc> matches no files" — dead once the glob matches something again.
155
+ "dead-glob": (projectDir, body, ctx) => {
156
+ const m = /`([^`]+)`/.exec(body);
157
+ if (!m) return false;
158
+ const re = globToRegExp(m[1]);
159
+ return ctx.repoFiles.some((f) => re.test(f));
160
+ },
161
+
162
+ // "<rel> is a frozen record and carries uncommitted modifications" — dead once it is committed.
163
+ // Immutability begins at commit, so the finding exists only while the edit is still in flight.
164
+ "frozen-record-edited": (projectDir, body) => {
165
+ const m = /·\s+frozen-record-edited\s+·\s+(\S+)\s+is a frozen record/.exec(body);
166
+ if (!m) return false;
167
+ const rel = m[1];
168
+ if (!fs.existsSync(path.join(projectDir, rel))) return true;
169
+ const res = spawnSync("git", ["diff", "--quiet", "HEAD", "--", rel], { cwd: projectDir, timeout: 5000 });
170
+ // status 0 = no uncommitted modifications. A git failure leaves the row standing.
171
+ return res.status === 0;
172
+ },
173
+
174
+ // "<src> changed; owning doc <doc> may need an update" — dead once the doc is newer than the
175
+ // source that implicated it, which is the same freshness test the drift report already applies.
176
+ "doc-implicated": (projectDir, body) => {
177
+ const m = /·\s+doc-implicated\s+·\s+(\S+)\s+changed;\s+owning doc\s+(\S+?)\s+may need/.exec(body);
178
+ if (!m) return false;
179
+ const [, src, doc] = m;
180
+ const mtime = (rel) => {
181
+ try {
182
+ return fs.statSync(path.join(projectDir, rel)).mtimeMs;
183
+ } catch (e) {
184
+ return null;
185
+ }
186
+ };
187
+ const docTime = mtime(doc);
188
+ const srcTime = mtime(src);
189
+ if (docTime === null) return false; // the doc is gone — a real finding, not a resolved one
190
+ if (srcTime === null) return true; // the source is gone; nothing implicates the doc any more
191
+ return docTime >= srcTime;
192
+ },
193
+ };
194
+
195
+ // Delete every row — `auto:` or `waived:` — whose condition no longer holds. Returns the count.
196
+ export function sweepResolvedRows(projectDir, repoFiles) {
197
+ const ctx = { repoFiles: repoFiles || listRepoFiles(projectDir) };
198
+ return resolveRows(projectDir, (row) => {
199
+ if (row.kind === "needs-session") return false; // a human judgment, never swept mechanically
200
+ for (const [kind, verify] of Object.entries(ROW_VERIFIERS)) {
201
+ if (!row.body.includes(kind)) continue;
202
+ try {
203
+ return verify(projectDir, row.body, ctx);
204
+ } catch (e) {
205
+ return false; // never delete a row because a verifier threw
206
+ }
207
+ }
208
+ return false;
209
+ });
210
+ }
211
+
129
212
  // Compute drift facts once; write the full report, return the one-line banner.
130
213
  function driftBanner(projectDir) {
131
214
  const repoFiles = listRepoFiles(projectDir);
215
+ // Re-verify before counting: the walls must act on conditions that are live NOW, not on rows
216
+ // that happen to still be written down.
217
+ const swept = sweepResolvedRows(projectDir, repoFiles);
132
218
  const dead = deadGlobs(projectDir, repoFiles);
133
219
  const queueRows = readQueue(projectDir);
134
220
  const counts = countRows(queueRows);
135
221
  const waivers = unexpiredWaivers(queueRows, "dead-glob", today());
136
222
  const unwaivedDead = dead.filter((d) => !waivers.some((w) => w.body.includes(d.glob)));
137
223
 
224
+ // A waiver becomes a wall the day it expires, so the queue says so a week ahead — the deferral
225
+ // ending should be a date somebody saw coming, not a commit that suddenly blocks.
226
+ const soon = new Date(Date.now() + 7 * 864e5).toISOString().slice(0, 10);
227
+ const expiringSoon = queueRows
228
+ .filter((r) => r.kind === "waived")
229
+ .map((r) => {
230
+ const d = (/expires=(\d{4}-\d{2}-\d{2})/.exec(r.body) || [])[1];
231
+ if (!d || d < today() || d > soon) return null;
232
+ // Drop the row's own raised-date prefix and its reason tail — the expiry and the finding are
233
+ // what a reader is scanning for here.
234
+ const finding = r.body.replace(/^\d{4}-\d{2}-\d{2}\s+·\s+/, "").split(" · reason:")[0];
235
+ return `expires ${d} · ${finding.slice(0, 120)}`;
236
+ })
237
+ .filter(Boolean)
238
+ .sort();
239
+
138
240
  // A queued dead-glob row covered by an unexpired waiver is resolved history — drop it,
139
241
  // so waiving a finding class actually empties the rows it supersedes.
140
242
  resolveRows(projectDir, (row) => {
@@ -174,7 +276,10 @@ function driftBanner(projectDir) {
174
276
  `## Docs older than owned code (${staleDocs.length})`,
175
277
  ...staleDocs.map((d) => `- ${d}`),
176
278
  "",
177
- `## Bookkeeping queue (${counts.unresolved} unresolved: ${counts.auto} auto · ${counts.note} note · ${counts.needsSession} needs-session · ${counts.waived} waived)`,
279
+ `## Bookkeeping queue (${counts.unresolved} unresolved: ${counts.auto} auto · ${counts.note} note · ${counts.needsSession} needs-session · ${counts.expiredWaived} expired-waived; ${counts.waived} waived total, ${swept} swept as resolved this session)`,
280
+ ...(expiringSoon.length
281
+ ? ["", `## Waivers expiring within 7 days (${expiringSoon.length})`, ...expiringSoon.map((w) => `- ${w}`)]
282
+ : []),
178
283
  ].join("\n");
179
284
  try {
180
285
  fs.writeFileSync(path.join(stateDir(projectDir), "drift-report.md"), report + "\n");
@@ -13,7 +13,12 @@ if (counts.unresolved === 0) {
13
13
  }
14
14
  console.error(
15
15
  `check-bookkeeping-clean: ${counts.unresolved} unresolved row(s) in docs/operations/bookkeeping.md ` +
16
- `(${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session). ` +
17
- "Drain them (bookkeeper agent), resolve them, or convert to waived: with reason + expires=."
16
+ `(${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session, ` +
17
+ `${counts.expiredWaived} expired-waived). ` +
18
+ "Drain them (bookkeeper agent), resolve them, or convert to waived: with reason + expires=. " +
19
+ (counts.expiredWaived > 0
20
+ ? "An expired waiver is a deferral that ran out: re-judge whether its reason still holds — " +
21
+ "re-waive with a fresh date only if it does, and never by default."
22
+ : "")
18
23
  );
19
24
  process.exit(1);
@@ -567,8 +567,11 @@ function stopGate(projectDir, payload) {
567
567
  return {
568
568
  decision: "block",
569
569
  reason:
570
- `${counts.unresolved} unresolved bookkeeping rows from this session (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session) in docs/operations/bookkeeping.md. ` +
570
+ `${counts.unresolved} unresolved bookkeeping rows from this session (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session, ${counts.expiredWaived} expired-waived) in docs/operations/bookkeeping.md. ` +
571
571
  "Dispatch the `bookkeeper` agent to drain them, resolve them now, or convert to `waived:` rows with reason + expiry. " +
572
+ (counts.expiredWaived > 0
573
+ ? "An expired waiver is a deferral that ran out — re-judge whether its reason still holds rather than re-waiving by default. "
574
+ : "") +
572
575
  "If implementation of the open plan is complete, also report it and offer the verification gate — never run it unbidden.",
573
576
  };
574
577
  }
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.19.89",
3
+ "version": "0.19.90",
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": "8d6f9b80d64d"
46
46
  }