@nurix/etna 0.20.22 → 0.20.23
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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"harness": "code",
|
|
3
3
|
"name": "code",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.19.1",
|
|
5
5
|
"files": [
|
|
6
6
|
".prettierignore",
|
|
7
7
|
".prettierrc.json",
|
|
@@ -25,5 +25,5 @@
|
|
|
25
25
|
}
|
|
26
26
|
],
|
|
27
27
|
"docsRoot": "docs",
|
|
28
|
-
"digest": "
|
|
28
|
+
"digest": "bc085dd8505c"
|
|
29
29
|
}
|
|
@@ -49,6 +49,22 @@ import {
|
|
|
49
49
|
pruneState,
|
|
50
50
|
} from "./lib/claims.mjs";
|
|
51
51
|
import { appendEvent } from "./lib/session-log.mjs";
|
|
52
|
+
import { wallsAllowlist } from "./lib/graph-store.mjs";
|
|
53
|
+
import { exitWallsAllowlist } from "./lib/exit-store.mjs";
|
|
54
|
+
import { wallEnabled } from "./lib/walls.mjs";
|
|
55
|
+
|
|
56
|
+
// Whether anything will ever read a row: a run whose allowlist switches off both walls the queue feeds —
|
|
57
|
+
// the Stop wall and the commit wall — has nobody to drain it, so its rows would only ride the run's output
|
|
58
|
+
// into the project (docs/harness/code/exit-check.md).
|
|
59
|
+
function queueHasReader(projectDir) {
|
|
60
|
+
let allowlist = null;
|
|
61
|
+
try {
|
|
62
|
+
allowlist = wallsAllowlist(projectDir) ?? exitWallsAllowlist(projectDir);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
allowlist = null;
|
|
65
|
+
}
|
|
66
|
+
return wallEnabled(allowlist, "queue") || wallEnabled(allowlist, "bookkeeping");
|
|
67
|
+
}
|
|
52
68
|
|
|
53
69
|
// ---------------------------------------------------------------------------
|
|
54
70
|
// SessionStart
|
|
@@ -566,7 +582,7 @@ function postToolUse(projectDir, payload) {
|
|
|
566
582
|
detectOutcomes(projectDir, meta, rows);
|
|
567
583
|
}
|
|
568
584
|
}
|
|
569
|
-
appendRows(projectDir, rows);
|
|
585
|
+
if (rows.length > 0 && queueHasReader(projectDir)) appendRows(projectDir, rows);
|
|
570
586
|
}
|
|
571
587
|
|
|
572
588
|
// ---------------------------------------------------------------------------
|
|
@@ -25,6 +25,7 @@ import { appendEvent, unresolvedDenials, sessionLogPath, malformedEventLines } f
|
|
|
25
25
|
import { approvedPackages, rejectedPackages } from "./lib/approved.mjs";
|
|
26
26
|
import { claimActivity, releaseActivity, recentForeignTouches } from "./lib/claims.mjs";
|
|
27
27
|
import { atStop, graphDenial, wallsAllowlist } from "./lib/graph-store.mjs";
|
|
28
|
+
import { exitAtStop, exitWallsAllowlist, recordUnreadable } from "./lib/exit-store.mjs";
|
|
28
29
|
import { WALLS, wallEnabled } from "./lib/walls.mjs";
|
|
29
30
|
|
|
30
31
|
// --- the build graph's switch ------------------------------------------------------------
|
|
@@ -32,12 +33,13 @@ import { WALLS, wallEnabled } from "./lib/walls.mjs";
|
|
|
32
33
|
// allowlist over lib/walls.mjs. Every wall it does not name is off for that session — because
|
|
33
34
|
// the compliance set assumes a session that can dispatch a bookkeeper and a user who can answer
|
|
34
35
|
// an ask, and a generation box has neither: its walls would be unresolvable blocks, not guard
|
|
35
|
-
// rails. No graph, or a graph with no `mode` block, leaves every wall exactly as it was.
|
|
36
|
+
// rails. No graph, or a graph with no `mode` block, leaves every wall exactly as it was. With no graph
|
|
37
|
+
// declaring one, an exit set (docs/harness/code/exit-check.md) may carry the same block.
|
|
36
38
|
let allowlistCache;
|
|
37
39
|
function wallOn(projectDir, name) {
|
|
38
40
|
if (allowlistCache === undefined) {
|
|
39
41
|
try {
|
|
40
|
-
allowlistCache = wallsAllowlist(projectDir);
|
|
42
|
+
allowlistCache = wallsAllowlist(projectDir) ?? exitWallsAllowlist(projectDir);
|
|
41
43
|
} catch (e) {
|
|
42
44
|
allowlistCache = null; // a broken graph never switches a wall off
|
|
43
45
|
}
|
|
@@ -524,6 +526,29 @@ function graphStop(projectDir, stopHookActive) {
|
|
|
524
526
|
};
|
|
525
527
|
}
|
|
526
528
|
|
|
529
|
+
// The exit check (docs/harness/code/exit-check.md): authority, so a crash blocks rather than passes. It
|
|
530
|
+
// blocks once — the box walls `.claude/state`, so the model cannot repair the file — and the second stop
|
|
531
|
+
// records `blocked`, so the node still ends failed instead of met.
|
|
532
|
+
function exitStop(projectDir, stopHookActive) {
|
|
533
|
+
let step;
|
|
534
|
+
try {
|
|
535
|
+
step = exitAtStop(projectDir);
|
|
536
|
+
} catch (e) {
|
|
537
|
+
const reason = `EXIT CHECK FAILED — ${e?.message || e}`;
|
|
538
|
+
if (!stopHookActive) {
|
|
539
|
+
return { decision: "block", reason: `${reason}. This node's exit set cannot be read, so it is not finished. (Said once — the next Stop ends the node blocked.)` };
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
recordUnreadable(projectDir, reason);
|
|
543
|
+
} catch (e2) {
|
|
544
|
+
/* nowhere to record it; the box seals without a verdict and the run owner's own check decides */
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
if (!step || step.allow) return null;
|
|
549
|
+
return { decision: "block", reason: step.block };
|
|
550
|
+
}
|
|
551
|
+
|
|
527
552
|
function stopGate(projectDir, payload) {
|
|
528
553
|
// The build graph is asked BEFORE the runtime's loop guard, and deliberately ignores it. The
|
|
529
554
|
// guard exists because an unbounded Stop hook is a hang; the walker is bounded instead — every
|
|
@@ -538,6 +563,9 @@ function stopGate(projectDir, payload) {
|
|
|
538
563
|
// hand-written graph should know it.
|
|
539
564
|
const graph = graphStop(projectDir, payload.stop_hook_active);
|
|
540
565
|
if (graph) return graph;
|
|
566
|
+
// The exit check is asked before the loop guard for the walker's reason: it is bounded by its own attempts.
|
|
567
|
+
const exit = exitStop(projectDir, payload.stop_hook_active);
|
|
568
|
+
if (exit) return exit;
|
|
541
569
|
|
|
542
570
|
if (payload.stop_hook_active) return null; // the runtime's infinite-loop guard — never re-block
|
|
543
571
|
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// harness/code/hooks/lib/exit-store.mjs — the exit check: what a NuStack generation box hands the harness
|
|
2
|
+
// instead of a build graph. The box writes `.claude/state/exit.json` naming the documents a node owes; at
|
|
3
|
+
// every Stop this module checks that each one EXISTS — never its shape — and hands the turn back naming what
|
|
4
|
+
// is missing until the attempts run out, then records `blocked` (docs/harness/code/exit-check.md).
|
|
5
|
+
//
|
|
6
|
+
// This is authority, not hygiene: the caller turns a thrown error into a block, never a pass.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { atomicWrite, stateDir } from "./state.mjs";
|
|
11
|
+
|
|
12
|
+
export const EXIT_VERSION = 1;
|
|
13
|
+
export const EXIT_FILE = "exit.json";
|
|
14
|
+
export const EXIT_VERDICT_FILE = "exit-verdict.json";
|
|
15
|
+
|
|
16
|
+
const SKIPPED_DIRS = new Set(["node_modules", ".git"]);
|
|
17
|
+
|
|
18
|
+
// Read through a plain join: absence is dormancy, and asking must never create the folder.
|
|
19
|
+
export function exitFile(projectDir) {
|
|
20
|
+
return path.join(projectDir, ".claude", "state", EXIT_FILE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function exitVerdictFile(projectDir) {
|
|
24
|
+
return path.join(stateDir(projectDir), EXIT_VERDICT_FILE);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Absent → null. Present but not a v1 exit set → throws: a set nobody can read is not a set that is met.
|
|
28
|
+
export function loadExit(projectDir) {
|
|
29
|
+
let text;
|
|
30
|
+
try {
|
|
31
|
+
text = fs.readFileSync(exitFile(projectDir), "utf8");
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const set = JSON.parse(text);
|
|
36
|
+
const attempts = set?.attempts;
|
|
37
|
+
if (!set || set.v !== EXIT_VERSION || !Array.isArray(set.owed) || !Number.isInteger(attempts?.used) || !Number.isInteger(attempts?.max)) {
|
|
38
|
+
throw new Error(`.claude/state/${EXIT_FILE} is not a v${EXIT_VERSION} exit set`);
|
|
39
|
+
}
|
|
40
|
+
return set;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// `*` matches within one path segment; everything else is literal.
|
|
44
|
+
export function globMatches(glob, rel) {
|
|
45
|
+
const pattern = glob
|
|
46
|
+
.split("*")
|
|
47
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
48
|
+
.join("[^/]+");
|
|
49
|
+
return new RegExp(`^${pattern}$`).test(rel);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isFile(projectDir, rel) {
|
|
53
|
+
try {
|
|
54
|
+
return fs.statSync(path.join(projectDir, rel)).isFile();
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// How many files match a glob, walking only below its literal prefix.
|
|
61
|
+
function globCount(projectDir, glob) {
|
|
62
|
+
const segments = glob.split("/");
|
|
63
|
+
const wild = segments.findIndex((segment) => segment.includes("*"));
|
|
64
|
+
const base = wild === -1 ? path.posix.dirname(glob) : segments.slice(0, wild).join("/");
|
|
65
|
+
let count = 0;
|
|
66
|
+
const walk = (rel) => {
|
|
67
|
+
let entries;
|
|
68
|
+
try {
|
|
69
|
+
entries = fs.readdirSync(path.join(projectDir, rel), { withFileTypes: true });
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (SKIPPED_DIRS.has(entry.name)) continue;
|
|
75
|
+
const child = rel ? `${rel}/${entry.name}` : entry.name;
|
|
76
|
+
if (entry.isDirectory()) walk(child);
|
|
77
|
+
else if (entry.isFile() && globMatches(glob, child)) count += 1;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
walk(base === "." ? "" : base);
|
|
81
|
+
return count;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The owed entries not on disk: a path that is not a file, or a glob matching fewer files than its floor.
|
|
85
|
+
export function missingOf(projectDir, set) {
|
|
86
|
+
const missing = [];
|
|
87
|
+
for (const entry of set.owed) {
|
|
88
|
+
if (typeof entry?.path === "string") {
|
|
89
|
+
if (!isFile(projectDir, entry.path)) missing.push({ type: entry.type, path: entry.path });
|
|
90
|
+
} else if (typeof entry?.glob === "string") {
|
|
91
|
+
const min = Number.isInteger(entry.min) && entry.min > 0 ? entry.min : 1;
|
|
92
|
+
const found = globCount(projectDir, entry.glob);
|
|
93
|
+
if (found < min) missing.push({ type: entry.type, glob: entry.glob, min, found });
|
|
94
|
+
} else {
|
|
95
|
+
throw new Error(`an owed entry names neither a path nor a glob: ${JSON.stringify(entry)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return missing;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function describeMissing(entry) {
|
|
102
|
+
return entry.path
|
|
103
|
+
? `\`${entry.path}\` (${entry.type})`
|
|
104
|
+
: `at least ${entry.min} file(s) matching \`${entry.glob}\` (${entry.type}, ${entry.found} found)`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function writeVerdict(projectDir, verdict) {
|
|
108
|
+
atomicWrite(exitVerdictFile(projectDir), `${JSON.stringify({ v: EXIT_VERSION, ...verdict }, null, 2)}\n`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Stop: null when dormant; `{ allow, verdict }` when the set is met or its attempts are spent; `{ block }` otherwise, charging one attempt.
|
|
112
|
+
export function exitAtStop(projectDir) {
|
|
113
|
+
const set = loadExit(projectDir);
|
|
114
|
+
if (!set) return null;
|
|
115
|
+
const missing = missingOf(projectDir, set);
|
|
116
|
+
const nodeSlug = set.nodeSlug ?? null;
|
|
117
|
+
if (missing.length === 0) {
|
|
118
|
+
writeVerdict(projectDir, { nodeSlug, verdict: "done", missing, attempts: set.attempts });
|
|
119
|
+
return { allow: true, verdict: "done", missing };
|
|
120
|
+
}
|
|
121
|
+
if (set.attempts.used >= set.attempts.max) {
|
|
122
|
+
writeVerdict(projectDir, { nodeSlug, verdict: "blocked", missing, attempts: set.attempts });
|
|
123
|
+
return { allow: true, verdict: "blocked", missing };
|
|
124
|
+
}
|
|
125
|
+
const attempts = { ...set.attempts, used: set.attempts.used + 1 };
|
|
126
|
+
atomicWrite(exitFile(projectDir), `${JSON.stringify({ ...set, attempts }, null, 2)}\n`);
|
|
127
|
+
const left = attempts.max - attempts.used;
|
|
128
|
+
return {
|
|
129
|
+
block:
|
|
130
|
+
`EXIT CHECK — this node owes documents that do not exist yet: ${missing.map(describeMissing).join("; ")}. Write them, then stop. ` +
|
|
131
|
+
(left > 0
|
|
132
|
+
? `This stop was handed back; ${left} more will be, and after that the node ends blocked.`
|
|
133
|
+
: "The next stop ends the node blocked if any of them is still missing."),
|
|
134
|
+
missing,
|
|
135
|
+
attempts,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A set that cannot be read still ends the node — as blocked, naming why — rather than as met.
|
|
140
|
+
export function recordUnreadable(projectDir, reason) {
|
|
141
|
+
writeVerdict(projectDir, { nodeSlug: null, verdict: "blocked", missing: [{ type: "exit-check", reason }], attempts: { used: 0, max: 0 } });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The set's `mode.walls` allowlist, or null. A broken file switches no wall off: hygiene fails open.
|
|
145
|
+
export function exitWallsAllowlist(projectDir) {
|
|
146
|
+
try {
|
|
147
|
+
const set = loadExit(projectDir);
|
|
148
|
+
return Array.isArray(set?.mode?.walls) ? set.mode.walls : null;
|
|
149
|
+
} catch (e) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/etna",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.23",
|
|
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": "722680de8132"
|
|
46
46
|
}
|