@nurix/etna 0.20.47 → 0.20.49
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 +4 -3
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/compliance.mjs +13 -692
- package/data/harnesses/code/hooks/hooks.json +1 -1
- package/data/harnesses/code/hooks/lib/backlog-detector.mjs +30 -10
- package/data/harnesses/code/hooks/lib/frozen.mjs +28 -11
- package/data/harnesses/code/hooks/lib/gates.mjs +633 -0
- package/data/harnesses/code/hooks/lib/ownership.mjs +4 -14
- package/data/harnesses/code/hooks/lib/protection-gate.mjs +208 -0
- package/data/harnesses/code/hooks/lib/records-door.mjs +12 -21
- package/data/harnesses/code/hooks/lib/repo.mjs +33 -7
- package/data/harnesses/code/hooks/lib/session-log.mjs +1 -2
- package/data/harnesses/code/hooks/lib/state.mjs +4 -10
- package/data/harnesses/code/hooks/protection.mjs +53 -0
- package/data/harnesses/etna/CLAUDE.md +2 -1
- package/data/harnesses/etna/harness.json +1 -1
- package/lib/install/plan.mjs +3 -1
- package/package.json +2 -2
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
{ "matcher": "Edit|Write|MultiEdit|Bash", "hooks": [ { "type": "command", "command": "f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/awareness.mjs\"; [ -f \"$f\" ] || exit 0; node \"$f\"", "timeout": 10 }, { "type": "command", "command": "f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/build-graph.mjs\"; [ -f \"$f\" ] || exit 0; node \"$f\"", "timeout": 10 } ] }
|
|
15
15
|
],
|
|
16
16
|
"PreToolUse": [
|
|
17
|
-
{ "matcher": "Edit|Write|MultiEdit|Bash", "hooks": [ { "type": "command", "command": "f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/compliance.mjs\"; [ -f \"$f\" ] || exit 0; node \"$f\"", "timeout": 10 } ] }
|
|
17
|
+
{ "matcher": "Edit|Write|MultiEdit|Bash", "hooks": [ { "type": "command", "command": "f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/compliance.mjs\"; [ -f \"$f\" ] || exit 0; node \"$f\"", "timeout": 10 }, { "type": "command", "command": "f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/protection.mjs\"; [ -f \"$f\" ] || exit 0; node \"$f\"", "timeout": 10, "onFailure": "block" } ] }
|
|
18
18
|
],
|
|
19
19
|
"Stop": [
|
|
20
20
|
{ "matcher": "", "hooks": [ { "type": "command", "command": "f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/compliance.mjs\"; [ -f \"$f\" ] || exit 0; node \"$f\"", "timeout": 15 } ] }
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// harness/code/hooks/lib/backlog-detector.mjs — rows added to or removed from the backlog since the
|
|
2
2
|
// last observation, emitted through the records door as `backlog` and `backlog.remove`. The last
|
|
3
3
|
// observation lives in .claude/state/backlog-seen.json; an unchanged mtime and size, or an unchanged
|
|
4
|
-
// hash, skips the parse.
|
|
4
|
+
// hash, skips the parse. A row counts as seen only once the door took it: with no `nustack` the
|
|
5
|
+
// detector observes nothing, and a failed emit keeps the prior rows and backs off before retrying.
|
|
6
|
+
// The row grammar mirrors NuStack's backlogRow.ts, anchor included.
|
|
5
7
|
// Fuller account: docs/harness/code/hooks.md § lib/backlog-detector.mjs.
|
|
6
8
|
|
|
7
9
|
import fs from "node:fs";
|
|
@@ -9,7 +11,11 @@ import path from "node:path";
|
|
|
9
11
|
import { createHash } from "node:crypto";
|
|
10
12
|
import { docsRoot } from "./repo.mjs";
|
|
11
13
|
import { stateDir } from "./state.mjs";
|
|
12
|
-
import { emitRecord } from "./records-door.mjs";
|
|
14
|
+
import { emitRecord, onPath } from "./records-door.mjs";
|
|
15
|
+
|
|
16
|
+
// How long a failed emit waits before the detector parses and offers the rows again — a hanging CLI
|
|
17
|
+
// otherwise costs the emit's spawn timeout on every PostToolUse.
|
|
18
|
+
export const BACKLOG_RETRY_MS = 300000;
|
|
13
19
|
|
|
14
20
|
// FNV-1a 64 over UTF-16 code units, first 8 hex — the same anchor NuStack's parser gives a row.
|
|
15
21
|
export function anchorOf(text) {
|
|
@@ -45,8 +51,15 @@ export function backlogFile(projectDir) {
|
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
// Diffs the backlog against the last observation and emits the difference. Returns { added, removed }
|
|
48
|
-
// (null when the file was unchanged and not parsed).
|
|
49
|
-
|
|
54
|
+
// (null when no CLI is on the PATH, the retry window is open, or the file was unchanged and not parsed).
|
|
55
|
+
// `available` probes for the CLI; tests inject it so they stay hermetic.
|
|
56
|
+
export function detectBacklog(
|
|
57
|
+
projectDir,
|
|
58
|
+
{ emit = emitRecord, parse = parseBacklog, available = () => Boolean(onPath("nustack", process.env.PATH || "")) } = {}
|
|
59
|
+
) {
|
|
60
|
+
// No CLI: observe nothing, so the first observation after it arrives offers every row — the door's
|
|
61
|
+
// idempotency key absorbs the ones it already holds.
|
|
62
|
+
if (!available()) return null;
|
|
50
63
|
const file = backlogFile(projectDir);
|
|
51
64
|
let stat;
|
|
52
65
|
try {
|
|
@@ -61,13 +74,14 @@ export function detectBacklog(projectDir, { emit = emitRecord, parse = parseBack
|
|
|
61
74
|
} catch (e) {
|
|
62
75
|
seen = null;
|
|
63
76
|
}
|
|
77
|
+
// A failed emit nulls mtimeMs, size and hash, so once the window closes the file is re-parsed.
|
|
78
|
+
if (seen && typeof seen.retryAfterMs === "number" && Date.now() < seen.retryAfterMs) return null;
|
|
64
79
|
if (seen && seen.mtimeMs === stat.mtimeMs && seen.size === stat.size) return null;
|
|
65
80
|
const text = fs.readFileSync(file, "utf8");
|
|
66
81
|
const hash = createHash("sha256").update(text).digest("hex");
|
|
67
|
-
const write = (
|
|
68
|
-
fs.writeFileSync(seenFile, JSON.stringify({ mtimeMs: stat.mtimeMs, size: stat.size, hash, rows }) + "\n");
|
|
82
|
+
const write = (observation) => fs.writeFileSync(seenFile, JSON.stringify(observation) + "\n");
|
|
69
83
|
if (seen && seen.hash === hash) {
|
|
70
|
-
write(seen.rows || {});
|
|
84
|
+
write({ mtimeMs: stat.mtimeMs, size: stat.size, hash, rows: seen.rows || {} });
|
|
71
85
|
return null;
|
|
72
86
|
}
|
|
73
87
|
const now = {};
|
|
@@ -75,8 +89,14 @@ export function detectBacklog(projectDir, { emit = emitRecord, parse = parseBack
|
|
|
75
89
|
const prior = (seen && seen.rows) || {};
|
|
76
90
|
const added = Object.keys(now).filter((anchor) => !prior[anchor]).map((anchor) => now[anchor]);
|
|
77
91
|
const removed = Object.keys(prior).filter((anchor) => !now[anchor]).map((anchor) => prior[anchor]);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
// Each kind lands or fails on its own: a landed kind moves the observation, a failed one keeps the
|
|
93
|
+
// prior rows for that kind, so the next attempt offers the same difference again.
|
|
94
|
+
const addedLanded = added.length === 0 || emit("backlog", added, { projectDir }) === "door";
|
|
95
|
+
const removedLanded = removed.length === 0 || emit("backlog.remove", removed, { projectDir }) === "door";
|
|
96
|
+
const rows = {};
|
|
97
|
+
for (const anchor of Object.keys(now)) if (prior[anchor] || addedLanded) rows[anchor] = now[anchor];
|
|
98
|
+
if (!removedLanded) for (const row of removed) rows[row.anchor] = row;
|
|
99
|
+
if (addedLanded && removedLanded) write({ mtimeMs: stat.mtimeMs, size: stat.size, hash, rows });
|
|
100
|
+
else write({ mtimeMs: null, size: null, hash: null, rows, retryAfterMs: Date.now() + BACKLOG_RETRY_MS });
|
|
81
101
|
return { added, removed };
|
|
82
102
|
}
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// Abandoned, an outcome-stamped migration runbook. Immutability begins at commit for changelog
|
|
4
4
|
// entries (a draft in the working tree is meant to be edited), at the status flip for plans, and
|
|
5
5
|
// at the Executed stamp for runbooks. Every check fails open: no git, unreadable file ⇒ not frozen.
|
|
6
|
+
// Each predicate takes `{ timeoutMs }`, a wall-clock budget its git calls share (default 5000); a
|
|
7
|
+
// spent budget or a timed-out call reads as not frozen, so a fail-closed caller never blocks on a
|
|
8
|
+
// slow git.
|
|
6
9
|
|
|
7
10
|
import fs from "node:fs";
|
|
8
11
|
import path from "node:path";
|
|
@@ -13,11 +16,18 @@ function relPosix(projectDir, filePath) {
|
|
|
13
16
|
return path.relative(projectDir, path.resolve(projectDir, filePath)).split(path.sep).join("/");
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
20
|
+
|
|
21
|
+
// Milliseconds left before `deadline`, or 0 once it has passed.
|
|
22
|
+
const remaining = (deadline) => Math.max(0, Math.floor(deadline - Date.now()));
|
|
23
|
+
|
|
24
|
+
// True when git tracks the file at HEAD (i.e. it has been committed at least once). A timeout or a
|
|
25
|
+
// spent budget answers false.
|
|
26
|
+
export function isCommitted(projectDir, filePath, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
27
|
+
if (!(timeoutMs > 0)) return false;
|
|
18
28
|
const rel = relPosix(projectDir, filePath);
|
|
19
|
-
const res = spawnSync("git", ["cat-file", "-e", `HEAD:${rel}`], { cwd: projectDir, stdio: "ignore" });
|
|
20
|
-
return res.status === 0;
|
|
29
|
+
const res = spawnSync("git", ["cat-file", "-e", `HEAD:${rel}`], { cwd: projectDir, stdio: "ignore", timeout: timeoutMs });
|
|
30
|
+
return !res.error && !res.signal && res.status === 0;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
function readSafe(projectDir, filePath) {
|
|
@@ -29,14 +39,14 @@ function readSafe(projectDir, filePath) {
|
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
// The predicate. Returns { frozen: boolean, reason?: string }.
|
|
32
|
-
export function frozenStatus(projectDir, filePath) {
|
|
42
|
+
export function frozenStatus(projectDir, filePath, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
33
43
|
const rel = relPosix(projectDir, filePath);
|
|
34
44
|
const root = docsRoot(projectDir);
|
|
35
45
|
// *.bkp.* siblings are install-time backups, not records — the maintenance fold edits and
|
|
36
46
|
// deletes them, which a frozen verdict would forbid.
|
|
37
47
|
if (rel.includes(".bkp.")) return { frozen: false };
|
|
38
48
|
if (rel.startsWith(`${root}/history/changelog/`) && rel.endsWith(".md")) {
|
|
39
|
-
if (isCommitted(projectDir, rel)) {
|
|
49
|
+
if (isCommitted(projectDir, rel, { timeoutMs })) {
|
|
40
50
|
return { frozen: true, reason: "committed changelog entry — immutability begins at commit" };
|
|
41
51
|
}
|
|
42
52
|
return { frozen: false };
|
|
@@ -62,16 +72,23 @@ export function frozenStatus(projectDir, filePath) {
|
|
|
62
72
|
// predicate: a record frozen only in the worktree is a freeze born in the current edit (a plan
|
|
63
73
|
// legitimately flipping to Complete), and flagging it walls the completion ceremony itself.
|
|
64
74
|
// Only an edit to something HEAD already froze is an evasion.
|
|
65
|
-
|
|
75
|
+
// A git call that errors or times out answers not frozen at once; only a real non-zero `rev-parse`
|
|
76
|
+
// (no repository) falls to the worktree predicate.
|
|
77
|
+
export function frozenStatusAtHead(projectDir, filePath, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
78
|
+
const deadline = Date.now() + timeoutMs;
|
|
66
79
|
const rel = relPosix(projectDir, filePath);
|
|
67
80
|
const root = docsRoot(projectDir);
|
|
68
81
|
if (rel.includes(".bkp.")) return { frozen: false };
|
|
69
82
|
if (rel.startsWith(`${root}/history/changelog/`) && rel.endsWith(".md")) {
|
|
70
|
-
return frozenStatus(projectDir, filePath); // already keyed on isCommitted
|
|
83
|
+
return frozenStatus(projectDir, filePath, { timeoutMs: remaining(deadline) }); // already keyed on isCommitted
|
|
71
84
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
if (!(remaining(deadline) > 0)) return { frozen: false };
|
|
86
|
+
const inRepo = spawnSync("git", ["rev-parse", "--git-dir"], { cwd: projectDir, stdio: "ignore", timeout: remaining(deadline) });
|
|
87
|
+
if (inRepo.error || inRepo.signal) return { frozen: false }; // git absent or timed out — never a gate
|
|
88
|
+
if (inRepo.status !== 0) return frozenStatus(projectDir, filePath, { timeoutMs: remaining(deadline) }); // gitless — the worktree is all there is
|
|
89
|
+
if (!(remaining(deadline) > 0)) return { frozen: false };
|
|
90
|
+
const res = spawnSync("git", ["show", `HEAD:${rel}`], { cwd: projectDir, encoding: "utf8", timeout: remaining(deadline) });
|
|
91
|
+
if (res.error || res.signal) return { frozen: false };
|
|
75
92
|
const headText = res.status === 0 ? res.stdout || "" : "";
|
|
76
93
|
if (!headText) return { frozen: false }; // never committed — a freeze born in this worktree is a ceremony, not an evasion
|
|
77
94
|
if (rel.startsWith(`${root}/history/plans/`) && /^\*\*Status:\*\*\s*(Complete|Abandoned)\b/m.test(headText)) {
|