@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.
- package/data/harness-index.json +1 -1
- package/data/harnesses/code/CLAUDE.md +1 -1
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/awareness.mjs +106 -1
- package/data/harnesses/code/hooks/check-bookkeeping-clean.mjs +7 -2
- package/data/harnesses/code/hooks/compliance.mjs +4 -1
- package/data/harnesses/code/hooks/lib/state.mjs +24 -6
- package/package.json +2 -2
package/data/harness-index.json
CHANGED
|
@@ -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.
|
|
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": "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
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")
|
|
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.
|
|
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": "
|
|
45
|
+
"etnaFingerprint": "8d6f9b80d64d"
|
|
46
46
|
}
|