@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.
- 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 +183 -111
- package/data/harnesses/code/hooks/check-bookkeeping-clean.mjs +7 -2
- package/data/harnesses/code/hooks/check-changelog-presence.mjs +4 -25
- package/data/harnesses/code/hooks/check-completion-integrity.mjs +1 -8
- package/data/harnesses/code/hooks/check-ownership.mjs +6 -8
- package/data/harnesses/code/hooks/compliance.mjs +47 -184
- package/data/harnesses/code/hooks/lib/approved.mjs +1 -9
- package/data/harnesses/code/hooks/lib/claims.mjs +1 -4
- package/data/harnesses/code/hooks/lib/exempt.mjs +32 -0
- package/data/harnesses/code/hooks/lib/frozen.mjs +24 -4
- package/data/harnesses/code/hooks/lib/ownership.mjs +24 -28
- package/data/harnesses/code/hooks/lib/plans.mjs +63 -0
- package/data/harnesses/code/hooks/lib/repo.mjs +68 -0
- package/data/harnesses/code/hooks/lib/session-log.mjs +2 -5
- package/data/harnesses/code/hooks/lib/state.mjs +36 -10
- package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +1 -1
- package/data/harnesses/delivery/harness.json +2 -2
- package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +1 -1
- package/data/harnesses/etna/harness.json +2 -2
- package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +1 -1
- package/package.json +2 -2
|
@@ -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`
|
|
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
|
|
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
|
|
|
@@ -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 = (
|
|
211
|
-
|
|
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` · `
|
|
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.
|
|
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": "
|
|
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` · `
|
|
18
|
+
Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `package-rejected` · `frozen-record-edited`.
|
|
@@ -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` · `
|
|
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.
|
|
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": "
|
|
45
|
+
"etnaFingerprint": "b52694cf6295"
|
|
46
46
|
}
|