@nurix/etna 0.19.91 → 0.20.0
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/bin/etna.js +0 -11
- package/data/catalog/rules/frontend.md +1 -1
- package/data/catalog/rules/general.md +3 -3
- package/data/catalog/rules/nomenclature.md +0 -4
- package/data/catalog/rules/pnpm.md +1 -1
- package/data/catalog/rules/testing.md +1 -0
- package/data/harness-index.json +1 -1
- package/data/harnesses/code/CLAUDE.md +14 -14
- package/data/harnesses/code/agents/bookkeeper.md +4 -1
- package/data/harnesses/code/agents/maintenance.md +3 -14
- package/data/harnesses/code/agents/performance-review.md +1 -1
- package/data/harnesses/code/codex/agents/performance-review.toml +1 -1
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/awareness.mjs +5 -169
- package/data/harnesses/code/hooks/compliance.mjs +38 -18
- package/data/harnesses/code/hooks/lib/ledger.mjs +36 -13
- package/data/harnesses/code/hooks/lib/state.mjs +85 -1
- package/data/harnesses/code/hooks/lib/sweep.mjs +172 -0
- package/data/harnesses/code/rules/frontend.md +1 -1
- package/data/harnesses/code/rules/general.md +3 -3
- package/data/harnesses/code/rules/nomenclature.md +0 -4
- package/data/harnesses/code/rules/pnpm.md +1 -1
- package/data/harnesses/code/rules/testing.md +1 -0
- package/data/harnesses/code/skills/boundary/SKILL.md +1 -1
- package/data/harnesses/code/skills/documentation/SKILL.md +1 -1
- package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +1 -1
- package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +14 -8
- package/data/harnesses/code/skills/documentation/templates/plan.md +9 -16
- package/data/harnesses/code/skills/testing/SKILL.md +1 -2
- package/data/harnesses/delivery/agents/maintenance.md +3 -14
- package/data/harnesses/delivery/harness.json +2 -2
- package/data/harnesses/delivery/hooks/generate-ownership-index.mjs +3 -1
- package/data/harnesses/delivery/rules/nomenclature.md +0 -4
- package/data/harnesses/delivery/skills/documentation/SKILL.md +1 -1
- package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +1 -1
- package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +14 -8
- package/data/harnesses/delivery/skills/documentation/templates/plan.md +9 -16
- package/data/harnesses/etna/agents/maintenance.md +3 -14
- package/data/harnesses/etna/agents/performance-review.md +1 -1
- package/data/harnesses/etna/codex/agents/performance-review.toml +1 -1
- package/data/harnesses/etna/harness.json +2 -2
- package/data/harnesses/etna/hooks/generate-ownership-index.mjs +3 -1
- package/data/harnesses/etna/rules/general.md +3 -3
- package/data/harnesses/etna/rules/nomenclature.md +0 -4
- package/data/harnesses/etna/rules/testing.md +1 -0
- package/data/harnesses/etna/skills/boundary/SKILL.md +1 -1
- package/data/harnesses/etna/skills/documentation/SKILL.md +1 -1
- package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +1 -1
- package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +14 -8
- package/data/harnesses/etna/skills/documentation/templates/plan.md +9 -16
- package/lib/codex/agents-md.mjs +1 -35
- package/lib/codex/hooks.mjs +0 -29
- package/lib/install/materialize.mjs +4 -34
- package/lib/install/plan.mjs +54 -10
- package/package.json +3 -3
- package/data/harnesses/code/hooks/check-bookkeeping-clean.mjs +0 -24
- package/data/harnesses/code/hooks/check-changelog-presence.mjs +0 -36
- package/data/harnesses/code/hooks/check-completion-integrity.mjs +0 -72
- package/data/harnesses/code/hooks/check-ownership.mjs +0 -55
- package/lib/codex/index.mjs +0 -63
- package/lib/codex/plugin-manifest.mjs +0 -42
- package/lib/codex/subagents.mjs +0 -26
package/lib/codex/agents-md.mjs
CHANGED
|
@@ -8,17 +8,9 @@
|
|
|
8
8
|
// Mirrors the installer's CLAUDE.md safety net: overwrite in place when git can recover it,
|
|
9
9
|
// otherwise back up the existing file and warn.
|
|
10
10
|
|
|
11
|
-
import { existsSync,
|
|
11
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
|
|
14
|
-
function isSymlink(path) {
|
|
15
|
-
try {
|
|
16
|
-
return lstatSync(path).isSymbolicLink();
|
|
17
|
-
} catch {
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
14
|
// Sorted base names of *.<ext> files directly under dir (e.g. rules/*.md → rule names).
|
|
23
15
|
function listByExt(dir, ext) {
|
|
24
16
|
if (!existsSync(dir)) return [];
|
|
@@ -86,29 +78,3 @@ export function buildCodexAdapter(bundledHarnessDir) {
|
|
|
86
78
|
lines.push('');
|
|
87
79
|
return lines.join('\n');
|
|
88
80
|
}
|
|
89
|
-
|
|
90
|
-
export function projectAgentsMd({ targetDir, bundledClaudeMd, bundledHarnessDir, projectHasGit }) {
|
|
91
|
-
if (!existsSync(bundledClaudeMd)) return null;
|
|
92
|
-
const dest = join(targetDir, 'AGENTS.md');
|
|
93
|
-
const existed = existsSync(dest) || isSymlink(dest);
|
|
94
|
-
let notice = null;
|
|
95
|
-
let warning = null;
|
|
96
|
-
if (existed) {
|
|
97
|
-
if (projectHasGit) {
|
|
98
|
-
notice = 'Overwrote existing AGENTS.md (tracked by git — `git checkout AGENTS.md` to restore).';
|
|
99
|
-
} else {
|
|
100
|
-
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
101
|
-
const backup = `${dest}.bak.${ts}`;
|
|
102
|
-
renameSync(dest, backup);
|
|
103
|
-
warning = `No .git here, so no version-control safety net — backed up existing AGENTS.md → ${backup}`;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
// Policy copied byte-for-byte; the generated adapter appended after a blank-line separator.
|
|
107
|
-
// The policy prefix is left exactly as authored — only a separator + adapter follow it.
|
|
108
|
-
const policy = readFileSync(bundledClaudeMd, 'utf8');
|
|
109
|
-
const adapter = bundledHarnessDir ? buildCodexAdapter(bundledHarnessDir) : '';
|
|
110
|
-
const separator = policy.endsWith('\n') ? '\n' : '\n\n';
|
|
111
|
-
const body = adapter ? policy + separator + adapter : policy;
|
|
112
|
-
writeFileSync(dest, body);
|
|
113
|
-
return { installed: 'AGENTS.md → ./', notice, warning };
|
|
114
|
-
}
|
package/lib/codex/hooks.mjs
CHANGED
|
@@ -14,8 +14,6 @@
|
|
|
14
14
|
// points at the project root, not the session's nested cwd. The Claude source hooks.json
|
|
15
15
|
// is left untouched — it stays valid for the CLI-installed .claude/settings.json path.
|
|
16
16
|
|
|
17
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
18
|
-
import { join, dirname } from 'node:path';
|
|
19
17
|
|
|
20
18
|
const PROJECT_DIR_TOKEN = /\$\{?CLAUDE_PROJECT_DIR\}?/;
|
|
21
19
|
|
|
@@ -28,30 +26,3 @@ export function toCodexCommand(command) {
|
|
|
28
26
|
command
|
|
29
27
|
);
|
|
30
28
|
}
|
|
31
|
-
|
|
32
|
-
export function projectHooks({ targetDir, bundledHarnessDir }) {
|
|
33
|
-
const srcJson = join(bundledHarnessDir, 'hooks', 'hooks.json');
|
|
34
|
-
if (!existsSync(srcJson)) return null;
|
|
35
|
-
let parsed;
|
|
36
|
-
try {
|
|
37
|
-
parsed = JSON.parse(readFileSync(srcJson, 'utf8'));
|
|
38
|
-
} catch {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
const out = {};
|
|
42
|
-
for (const [event, groups] of Object.entries(parsed.hooks || {})) {
|
|
43
|
-
const kept = [];
|
|
44
|
-
for (const group of groups) {
|
|
45
|
-
const hooks = (group.hooks || [])
|
|
46
|
-
.filter((h) => h.type === 'command')
|
|
47
|
-
.map((h) => ({ ...h, command: toCodexCommand(h.command) }));
|
|
48
|
-
if (hooks.length) kept.push({ ...group, hooks });
|
|
49
|
-
}
|
|
50
|
-
if (kept.length) out[event] = kept;
|
|
51
|
-
}
|
|
52
|
-
if (!Object.keys(out).length) return null;
|
|
53
|
-
const dest = join(targetDir, '.codex', 'hooks.json');
|
|
54
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
55
|
-
writeFileSync(dest, JSON.stringify({ hooks: out }, null, 2) + '\n');
|
|
56
|
-
return { installed: '.codex/hooks.json' };
|
|
57
|
-
}
|
|
@@ -36,9 +36,6 @@ import { projectSkills } from '../codex/skills.mjs';
|
|
|
36
36
|
// mergeHooks is additive, so an upgrade must explicitly remove both the stale settings.json
|
|
37
37
|
// entries pointing at these and the leftover script files they reference.
|
|
38
38
|
const RETIRED_HOOKS = [
|
|
39
|
-
'session-stop.js',
|
|
40
|
-
'post-tool-failure.js',
|
|
41
|
-
'post-commit.js',
|
|
42
39
|
'generate-ownership-index.mjs', // folded into awareness.mjs — unretired, the index would inject twice
|
|
43
40
|
];
|
|
44
41
|
|
|
@@ -89,28 +86,6 @@ function writeJson(path, value) {
|
|
|
89
86
|
writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
|
|
90
87
|
}
|
|
91
88
|
|
|
92
|
-
// Clean up any leftover marketplace/enabledPlugins entries from a previous 0.1.x
|
|
93
|
-
// install. After 0.2.0 we don't use the marketplace flow at all — leaving stale
|
|
94
|
-
// entries causes Claude Code to also resolve the plugin from the global cache,
|
|
95
|
-
// double-loading the same skills under a different namespace.
|
|
96
|
-
function migrateFromPluginInstall(targetDir, pluginName) {
|
|
97
|
-
const removed = [];
|
|
98
|
-
const projectSettingsPath = join(targetDir, '.claude', 'settings.json');
|
|
99
|
-
if (existsSync(projectSettingsPath)) {
|
|
100
|
-
const s = readJson(projectSettingsPath);
|
|
101
|
-
if (s.enabledPlugins) {
|
|
102
|
-
const keys = Object.keys(s.enabledPlugins).filter((k) => k.startsWith(`${pluginName}@`));
|
|
103
|
-
for (const k of keys) {
|
|
104
|
-
delete s.enabledPlugins[k];
|
|
105
|
-
removed.push(`enabledPlugins["${k}"] in ${projectSettingsPath}`);
|
|
106
|
-
}
|
|
107
|
-
if (Object.keys(s.enabledPlugins).length === 0) delete s.enabledPlugins;
|
|
108
|
-
writeJson(projectSettingsPath, s);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return removed;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
89
|
// Remove settings.json hook entries and .claude/hooks/ files for retired hook scripts.
|
|
115
90
|
// Bundle-aware: a name on the retired list is skipped while THIS bundle still ships it —
|
|
116
91
|
// several harnesses share the list, and they retire the same script at different versions.
|
|
@@ -305,18 +280,17 @@ function mergeHooks(targetDir, bundledHooksJson) {
|
|
|
305
280
|
* entries pointing at scripts this bundle no longer carries, and folding the harness's hooks into
|
|
306
281
|
* a settings.json that already belongs to somebody.
|
|
307
282
|
*
|
|
308
|
-
* `pluginName` is used only for the legacy marketplace cleanup. `projectHasGit` decides the backup
|
|
309
283
|
* policy for the harness-owned artifacts a refresh overwrites — git is the safety net when it is
|
|
310
284
|
* there, a `.bak.<timestamp>` copy when it is not.
|
|
311
285
|
*
|
|
312
286
|
* Returns the accounting the caller renders: `installed`, `notices`, `warnings`, plus `owned` (the
|
|
313
287
|
* manifest written into `.claude/harness.json`) and `meta` (the bundle marker).
|
|
314
288
|
*/
|
|
315
|
-
export function materializeHarness({ bundledHarnessDir, targetDir
|
|
316
|
-
const { files, owned, meta } = planHarnessFiles(bundledHarnessDir);
|
|
289
|
+
export function materializeHarness({ bundledHarnessDir, targetDir }) {
|
|
290
|
+
const { files, owned, meta, capabilityNotes } = planHarnessFiles(bundledHarnessDir, targetDir);
|
|
317
291
|
|
|
318
292
|
const installed = [];
|
|
319
|
-
const notices = [];
|
|
293
|
+
const notices = [...(capabilityNotes || [])];
|
|
320
294
|
const warnings = [];
|
|
321
295
|
|
|
322
296
|
// Read BEFORE anything is written: this is what a prior install recorded as harness-owned, and
|
|
@@ -333,7 +307,6 @@ export function materializeHarness({ bundledHarnessDir, targetDir, pluginName, p
|
|
|
333
307
|
// makes the fold duty visible to the next session. The harness is deliberately unapologetic
|
|
334
308
|
// about its own process; the backup is how it stays honest about yours.
|
|
335
309
|
const firstContact = isFirstContact(targetDir);
|
|
336
|
-
void projectHasGit; // kept for caller compatibility; the .bkp rule does not branch on it
|
|
337
310
|
|
|
338
311
|
// The Codex mirrors are rebuilt wholesale, not merged: they are byte projections of the Claude
|
|
339
312
|
// tree, so a file left behind from a prior version is a skill Codex still sees after the harness
|
|
@@ -408,7 +381,7 @@ export function materializeHarness({ bundledHarnessDir, targetDir, pluginName, p
|
|
|
408
381
|
// `--skill=<name>` and deliberately absent from `owned`, which is what makes it survive pruning —
|
|
409
382
|
// was present for Claude and missing for Codex after every roll. Ordering is load-bearing: run
|
|
410
383
|
// before the prunes and a skill this bundle dropped would be mirrored straight back.
|
|
411
|
-
const mirrored = projectSkills({ targetDir,
|
|
384
|
+
const mirrored = projectSkills({ targetDir, snapshot: false });
|
|
412
385
|
if (mirrored?.installed) installed.push(mirrored.installed);
|
|
413
386
|
|
|
414
387
|
// Retire hooks earlier harness versions shipped, before merging, so the additive merge never
|
|
@@ -418,9 +391,6 @@ export function materializeHarness({ bundledHarnessDir, targetDir, pluginName, p
|
|
|
418
391
|
const added = mergeHooks(targetDir, join(bundledHarnessDir, 'hooks', 'hooks.json'));
|
|
419
392
|
if (added > 0) installed.push(`${added} hook(s) → .claude/settings.json`);
|
|
420
393
|
|
|
421
|
-
for (const entry of migrateFromPluginInstall(targetDir, pluginName)) {
|
|
422
|
-
notices.push(`removed stale ${entry}`);
|
|
423
|
-
}
|
|
424
394
|
|
|
425
395
|
return { installed, notices, warnings, owned, meta };
|
|
426
396
|
}
|
package/lib/install/plan.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export const PLUGIN_SUBDIRS = ['skills', 'agents', 'rules', 'scripts', 'hooks'];
|
|
|
45
45
|
export const PRUNABLE_SUBDIRS = ['skills', 'agents', 'rules', 'scripts'];
|
|
46
46
|
|
|
47
47
|
// Codex targets rebuilt wholesale on every install rather than merged, so a refresh must delete
|
|
48
|
-
// them first.
|
|
48
|
+
// them first.
|
|
49
49
|
export const CODEX_REBUILT_DIRS = ['.agents/skills', '.codex/agents'];
|
|
50
50
|
|
|
51
51
|
// Every file under dir, as posix paths relative to it. Posix regardless of host, because these
|
|
@@ -165,7 +165,49 @@ function codexHooks(bundleDir) {
|
|
|
165
165
|
* Throws when the bundle has no CLAUDE.md: that is the one file every harness must carry, and a
|
|
166
166
|
* harness without a policy is not a harness.
|
|
167
167
|
*/
|
|
168
|
-
|
|
168
|
+
// The one capability-gated rule (owner ruling 2026-08-15): `cloudflare` ships only where a
|
|
169
|
+
// Worker exists — wrangler.jsonc/wrangler.toml at the target root or one directory level deep
|
|
170
|
+
// (a monorepo's worker package), node_modules excluded. Every other rule ships everywhere by
|
|
171
|
+
// decision. Detection re-runs on every refresh, so `owned.rules` records the filtered truth and
|
|
172
|
+
// the prune/add diff converges a repo whose Worker appears or disappears.
|
|
173
|
+
const CAPABILITY_RULES = { 'cloudflare.md': targetHasWorkerConfig };
|
|
174
|
+
|
|
175
|
+
function targetHasWorkerConfig(targetDir) {
|
|
176
|
+
const names = ['wrangler.jsonc', 'wrangler.toml'];
|
|
177
|
+
if (names.some((n) => existsSync(join(targetDir, n)))) return true;
|
|
178
|
+
let entries = [];
|
|
179
|
+
try {
|
|
180
|
+
entries = readdirSync(targetDir, { withFileTypes: true });
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
for (const e of entries) {
|
|
185
|
+
if (!e.isDirectory() || e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
186
|
+
if (names.some((n) => existsSync(join(targetDir, e.name, n)))) return true;
|
|
187
|
+
// one more level: packages/*/wrangler.jsonc — the common monorepo shape
|
|
188
|
+
let sub = [];
|
|
189
|
+
try {
|
|
190
|
+
sub = readdirSync(join(targetDir, e.name), { withFileTypes: true });
|
|
191
|
+
} catch {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
for (const s of sub) {
|
|
195
|
+
if (!s.isDirectory() || s.name === 'node_modules' || s.name.startsWith('.')) continue;
|
|
196
|
+
if (names.some((n) => existsSync(join(targetDir, e.name, s.name, n)))) return true;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A rule the target's capabilities exclude. No targetDir (payload verification, byte-identity
|
|
203
|
+
// tests) means no filtering — the plan then describes the full bundle.
|
|
204
|
+
function ruleExcluded(sub, rel, targetDir) {
|
|
205
|
+
if (!targetDir || sub !== 'rules') return false;
|
|
206
|
+
const gate = CAPABILITY_RULES[rel];
|
|
207
|
+
return !!gate && !gate(targetDir);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function planHarnessFiles(bundleDir, targetDir = null) {
|
|
169
211
|
const claudeMdPath = join(bundleDir, 'CLAUDE.md');
|
|
170
212
|
if (!existsSync(claudeMdPath)) {
|
|
171
213
|
throw new Error(`Bundled CLAUDE.md missing at ${claudeMdPath}. Rebuild package data.`);
|
|
@@ -177,8 +219,13 @@ export function planHarnessFiles(bundleDir) {
|
|
|
177
219
|
|
|
178
220
|
// The component subtrees. This is the mapping the whole module exists for: a bundle says
|
|
179
221
|
// `skills/design/SKILL.md`, a project says `.claude/skills/design/SKILL.md`.
|
|
222
|
+
const excludedRules = [];
|
|
180
223
|
for (const sub of PLUGIN_SUBDIRS) {
|
|
181
224
|
for (const rel of filesUnder(join(bundleDir, sub))) {
|
|
225
|
+
if (ruleExcluded(sub, rel, targetDir)) {
|
|
226
|
+
excludedRules.push(rel);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
182
229
|
files.push({
|
|
183
230
|
path: `.claude/${sub}/${rel}`,
|
|
184
231
|
content: readFileSync(join(bundleDir, sub, rel), 'utf8'),
|
|
@@ -191,7 +238,10 @@ export function planHarnessFiles(bundleDir) {
|
|
|
191
238
|
// installer, so if these are not in the payload a downloaded project has no Codex support at
|
|
192
239
|
// all and no way to generate it.
|
|
193
240
|
const owned = bundledOwned(bundleDir);
|
|
194
|
-
|
|
241
|
+
if (excludedRules.length) owned.rules = owned.rules.filter((r) => !excludedRules.includes(r));
|
|
242
|
+
const capabilityNotes = excludedRules.map(
|
|
243
|
+
(r) => `Rule ${r} not installed — no wrangler.jsonc/wrangler.toml found (capability-gated; a refresh after one appears adds it).`
|
|
244
|
+
);
|
|
195
245
|
|
|
196
246
|
const adapter = buildCodexAdapter(bundleDir);
|
|
197
247
|
const separator = claudeMd.endsWith('\n') ? '\n' : '\n\n';
|
|
@@ -200,7 +250,6 @@ export function planHarnessFiles(bundleDir) {
|
|
|
200
250
|
content: adapter ? claudeMd + separator + adapter : claudeMd,
|
|
201
251
|
policy: 'own',
|
|
202
252
|
});
|
|
203
|
-
codexOwned.push('AGENTS.md');
|
|
204
253
|
|
|
205
254
|
// `.agents/skills/` is Codex's official scan path and a byte mirror of `.claude/skills/`. Both
|
|
206
255
|
// runtimes require exactly name+description in SKILL.md frontmatter, so the mirrored files are
|
|
@@ -214,7 +263,6 @@ export function planHarnessFiles(bundleDir) {
|
|
|
214
263
|
policy: 'own',
|
|
215
264
|
});
|
|
216
265
|
}
|
|
217
|
-
codexOwned.push('.agents/skills');
|
|
218
266
|
}
|
|
219
267
|
|
|
220
268
|
const tomls = listByExt(join(bundleDir, 'codex', 'agents'), '.toml');
|
|
@@ -226,17 +274,13 @@ export function planHarnessFiles(bundleDir) {
|
|
|
226
274
|
policy: 'own',
|
|
227
275
|
});
|
|
228
276
|
}
|
|
229
|
-
codexOwned.push('.codex/agents');
|
|
230
277
|
}
|
|
231
278
|
|
|
232
279
|
const hooks = codexHooks(bundleDir);
|
|
233
280
|
if (hooks) {
|
|
234
281
|
files.push({ path: '.codex/hooks.json', content: hooks, policy: 'own' });
|
|
235
|
-
codexOwned.push('.codex/hooks.json');
|
|
236
282
|
}
|
|
237
283
|
|
|
238
|
-
owned.codex = codexOwned;
|
|
239
|
-
|
|
240
284
|
// The marker, at the path the CLI writes and the maintenance agent resolves the installed
|
|
241
285
|
// version from — never at the bundle root, where nothing reads it.
|
|
242
286
|
const markerPath = join(bundleDir, 'harness.json');
|
|
@@ -267,5 +311,5 @@ export function planHarnessFiles(bundleDir) {
|
|
|
267
311
|
seen.add(file.path);
|
|
268
312
|
}
|
|
269
313
|
|
|
270
|
-
return { files, owned, meta };
|
|
314
|
+
return { files, owned, meta, capabilityNotes };
|
|
271
315
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/etna",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
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": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"node": ">=18.0.0"
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
|
-
"test": "node --test 'tests/**/*.test.mjs'",
|
|
22
|
+
"test": "node --test 'tests/**/*.test.mjs' '../../harness/code/hooks/tests/*.test.mjs'",
|
|
23
23
|
"build:data": "node scripts/build-data.js",
|
|
24
24
|
"prepack": "node scripts/build-data.js",
|
|
25
25
|
"validate:codex-agents": "node lib/codex/validate-codex-agents.mjs"
|
|
@@ -42,5 +42,5 @@
|
|
|
42
42
|
"agent",
|
|
43
43
|
"plugin"
|
|
44
44
|
],
|
|
45
|
-
"etnaFingerprint": "
|
|
45
|
+
"etnaFingerprint": "067206dbe15a"
|
|
46
46
|
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// harness/code/hooks/check-bookkeeping-clean.mjs — CI backstop: no unresolved bookkeeping rows
|
|
2
|
-
// (auto:/note:/needs-session:) may reach the remote. waived: rows pass — they are the recorded,
|
|
3
|
-
// PR-reviewable exceptions. Exit 1 when unresolved rows exist.
|
|
4
|
-
|
|
5
|
-
import { readQueue, countRows } from "./lib/state.mjs";
|
|
6
|
-
|
|
7
|
-
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
8
|
-
const counts = countRows(readQueue(projectDir));
|
|
9
|
-
|
|
10
|
-
if (counts.unresolved === 0) {
|
|
11
|
-
console.log(`check-bookkeeping-clean: OK (${counts.waived} waived row(s))`);
|
|
12
|
-
process.exit(0);
|
|
13
|
-
}
|
|
14
|
-
console.error(
|
|
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
|
-
`${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
|
-
: "")
|
|
23
|
-
);
|
|
24
|
-
process.exit(1);
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// harness/code/hooks/check-changelog-presence.mjs — CI backstop: the commit under test (HEAD)
|
|
2
|
-
// carries a docs/history/changelog/ entry unless it is exempt by the changelog rule's closed list,
|
|
3
|
-
// judged mechanically: lockfile-only, harness-file-only, pure renames, changelog housekeeping,
|
|
4
|
-
// or a bot author. Exit 1 when a non-exempt HEAD stages no entry.
|
|
5
|
-
|
|
6
|
-
import { changelogExemptFile } from "./lib/exempt.mjs";
|
|
7
|
-
import { git as gitAt } from "./lib/repo.mjs";
|
|
8
|
-
|
|
9
|
-
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
10
|
-
const git = (args) => gitAt(projectDir, args);
|
|
11
|
-
|
|
12
|
-
const nameStatus = git(["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", "HEAD"]) || "";
|
|
13
|
-
const entries = nameStatus
|
|
14
|
-
.split("\n")
|
|
15
|
-
.filter(Boolean)
|
|
16
|
-
.map((line) => {
|
|
17
|
-
const [status, ...files] = line.split("\t");
|
|
18
|
-
return { status, file: files[files.length - 1] };
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const author = git(["log", "-1", "--format=%an <%ae>"]) || "";
|
|
23
|
-
const isBot = /\[bot\]|actions@github\.com/i.test(author);
|
|
24
|
-
const allRenames = entries.length > 0 && entries.every((e) => e.status.startsWith("R"));
|
|
25
|
-
const allExempt = entries.length === 0 || entries.every((e) => changelogExemptFile(e.file));
|
|
26
|
-
const hasEntry = entries.some((e) => e.file.startsWith("docs/history/changelog/") && e.file.endsWith(".md"));
|
|
27
|
-
|
|
28
|
-
if (isBot || allRenames || allExempt || hasEntry) {
|
|
29
|
-
console.log("check-changelog-presence: OK");
|
|
30
|
-
process.exit(0);
|
|
31
|
-
}
|
|
32
|
-
console.error(
|
|
33
|
-
"check-changelog-presence: HEAD is a non-exempt commit carrying no docs/history/changelog/ entry. " +
|
|
34
|
-
"Every non-exempt commit ships its entry inside it (exempt: release/bot, harness installs and bumps, lockfile-only, pure renames, changelog housekeeping)."
|
|
35
|
-
);
|
|
36
|
-
process.exit(1);
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
// harness/code/hooks/check-completion-integrity.mjs — CI backstop for plan completion. Scope:
|
|
2
|
-
// plans in the Gates: format only (legacy plans predate the contract and are history). A Complete
|
|
3
|
-
// plan must show a fully discharged surface: no unchecked Done Criteria boxes, the Spec'd LLD
|
|
4
|
-
// flipped active, and the behavioral checklist present. An Abandoned plan must reference the
|
|
5
|
-
// backlog (undischarged obligations outlive the frozen file there). Exit 1 on any violation.
|
|
6
|
-
|
|
7
|
-
import fs from "node:fs";
|
|
8
|
-
import path from "node:path";
|
|
9
|
-
import { frontmatter, field } from "./lib/ownership.mjs";
|
|
10
|
-
import { undischargedRows } from "./lib/ledger.mjs";
|
|
11
|
-
import { readSafe } from "./lib/repo.mjs";
|
|
12
|
-
|
|
13
|
-
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
14
|
-
const plansDir = path.join(projectDir, "docs", "history", "plans");
|
|
15
|
-
|
|
16
|
-
let failures = 0;
|
|
17
|
-
const fail = (msg) => {
|
|
18
|
-
failures += 1;
|
|
19
|
-
console.error(`check-completion-integrity: ${msg}`);
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
let plans = [];
|
|
23
|
-
try {
|
|
24
|
-
plans = fs.readdirSync(plansDir).filter((f) => f.endsWith(".md"));
|
|
25
|
-
} catch (e) {
|
|
26
|
-
plans = [];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
for (const file of plans) {
|
|
30
|
-
const text = readSafe(path.join(plansDir, file));
|
|
31
|
-
if (!/^\*\*Gates:\*\*/m.test(text)) continue; // legacy plan — out of contract
|
|
32
|
-
const status = (/^\*\*Status:\*\*\s*(\S+)/m.exec(text) || [])[1];
|
|
33
|
-
|
|
34
|
-
if (status === "Complete") {
|
|
35
|
-
const unchecked = (text.match(/^- \[ \]/gm) || []).length;
|
|
36
|
-
if (unchecked > 0) fail(`${file} is Complete with ${unchecked} unchecked Done Criteria box(es)`);
|
|
37
|
-
|
|
38
|
-
// The ledger is discharged per row, in the file — a Done Criteria checkbox asserting "every
|
|
39
|
-
// row discharged" is exactly the checkbox-nothing-reads failure the doc ledger existed to fix.
|
|
40
|
-
const rows = undischargedRows(text);
|
|
41
|
-
if (rows.length > 0) {
|
|
42
|
-
fail(`${file} is Complete with ${rows.length} Test Obligations row(s) missing a discharge outcome (#${rows.join(", #")})`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// EVERY Spec line, not the first. A plan may implement against several feature docs, and a
|
|
46
|
-
// single-match parse silently exempts all but one — the same singular assumption the Doc
|
|
47
|
-
// Obligations ledger exists to break, reintroduced here where nothing would surface it.
|
|
48
|
-
const specs = [...text.matchAll(/^\*\*Spec:\*\*\s*\[[^\]]*\]\(([^)]+)\)/gm)].map((m) => m[1]);
|
|
49
|
-
for (const spec of specs) {
|
|
50
|
-
const specPath = path.resolve(plansDir, spec);
|
|
51
|
-
const specText = readSafe(specPath);
|
|
52
|
-
const specStatus = (field(frontmatter(specText), "status") || "").toString();
|
|
53
|
-
if (specText && specStatus !== "active") {
|
|
54
|
-
fail(`${file} is Complete but its Spec LLD ${spec} is status: ${specStatus || "unknown"} (expected active)`);
|
|
55
|
-
}
|
|
56
|
-
const feature = path.basename(specPath, ".md");
|
|
57
|
-
if (!fs.existsSync(path.join(projectDir, "docs", "tests", `${feature}.md`))) {
|
|
58
|
-
fail(`${file} is Complete but docs/tests/${feature}.md (the behavioral checklist) is missing`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (status === "Abandoned" && !/backlog/i.test(text)) {
|
|
64
|
-
fail(`${file} is Abandoned without referencing the backlog — undischarged obligations must outlive the frozen plan`);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (failures === 0) {
|
|
69
|
-
console.log("check-completion-integrity: OK");
|
|
70
|
-
process.exit(0);
|
|
71
|
-
}
|
|
72
|
-
process.exit(1);
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
// harness/code/hooks/check-ownership.mjs — CI backstop for docs/features/ ownership. Two checks,
|
|
2
|
-
// one walk of the tree:
|
|
3
|
-
//
|
|
4
|
-
// 1. dead-glob — every owns: glob matches at least one file
|
|
5
|
-
// 2. ownership-overlap — no two feature docs claim a common file at EQUAL specificity, which
|
|
6
|
-
// leaves "most specific wins" unable to pick an owner
|
|
7
|
-
//
|
|
8
|
-
// Either is silenced by an unexpired `waived:` row in docs/operations/bookkeeping.md naming its
|
|
9
|
-
// kind plus the offending glob (dead-glob) or the shared path (ownership-overlap) in backticks.
|
|
10
|
-
// Exit 1 on anything unwaived.
|
|
11
|
-
//
|
|
12
|
-
// A glob at strictly HIGHER specificity carving a sub-path out of a broader `**` is the designed
|
|
13
|
-
// layering, not a conflict — only an exact tie is reported.
|
|
14
|
-
|
|
15
|
-
import { deadGlobs, listRepoFiles, overlaps } from "./lib/ownership.mjs";
|
|
16
|
-
import { readQueue, waiverCovers } from "./lib/state.mjs";
|
|
17
|
-
|
|
18
|
-
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
19
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
20
|
-
|
|
21
|
-
const files = listRepoFiles(projectDir);
|
|
22
|
-
const queue = readQueue(projectDir);
|
|
23
|
-
|
|
24
|
-
const dead = deadGlobs(projectDir, files);
|
|
25
|
-
const unwaivedDead = dead.filter((d) => !waiverCovers(queue, "dead-glob", d.glob, today));
|
|
26
|
-
|
|
27
|
-
const conflicts = overlaps(projectDir, files);
|
|
28
|
-
const unwaivedOverlaps = conflicts.filter((c) => !waiverCovers(queue, "ownership-overlap", c.example, today));
|
|
29
|
-
|
|
30
|
-
if (unwaivedDead.length === 0 && unwaivedOverlaps.length === 0) {
|
|
31
|
-
console.log(
|
|
32
|
-
`check-ownership: OK (${dead.length} dead glob(s), ${conflicts.length} equal-specificity overlap(s), all waived or none)`,
|
|
33
|
-
);
|
|
34
|
-
process.exit(0);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (unwaivedDead.length > 0) {
|
|
38
|
-
console.error(`check-ownership: ${unwaivedDead.length} unwaived dead owns: glob(s):`);
|
|
39
|
-
for (const d of unwaivedDead) console.error(` ${d.glob} → ${d.doc}`);
|
|
40
|
-
console.error("Fix the owning doc's owns: frontmatter, or add a `waived:` dead-glob row naming the glob in backticks, with reason + expires=.");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (unwaivedOverlaps.length > 0) {
|
|
44
|
-
console.error(`check-ownership: ${unwaivedOverlaps.length} unwaived equal-specificity overlap(s):`);
|
|
45
|
-
for (const c of unwaivedOverlaps) {
|
|
46
|
-
console.error(` ${c.example}`);
|
|
47
|
-
console.error(` ${c.a.glob} → ${c.a.doc}`);
|
|
48
|
-
console.error(` ${c.b.glob} → ${c.b.doc}`);
|
|
49
|
-
}
|
|
50
|
-
console.error(
|
|
51
|
-
"Narrow one doc's glob so a single owner wins, or add a `waived:` ownership-overlap row naming the shared path in backticks with reason + expires=.",
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
process.exit(1);
|
package/lib/codex/index.mjs
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
// packages/cli/lib/codex/index.mjs
|
|
2
|
-
// Claude→Codex projection entry. Derives a repo's Codex-native artifacts
|
|
3
|
-
// (AGENTS.md, .agents/skills/, .codex/agents/, .codex/hooks.json) from the
|
|
4
|
-
// just-installed Claude tree + the harness bundle. Consumed by BOTH shippers —
|
|
5
|
-
// the installer (bin/etna.js) and the marketplace publisher (publish.mjs) — so
|
|
6
|
-
// the two distribution channels can never diverge (the same "keep in agreement"
|
|
7
|
-
// discipline as the evals/fixtures ship-filter). MCP and a settings/config.toml
|
|
8
|
-
// equivalent are deliberately OUT of scope (owner decision 2026-07-13); see
|
|
9
|
-
// docs/features/codex-support.md.
|
|
10
|
-
|
|
11
|
-
import { existsSync, rmSync } from 'node:fs';
|
|
12
|
-
import { join } from 'node:path';
|
|
13
|
-
import { projectAgentsMd } from './agents-md.mjs';
|
|
14
|
-
import { projectSkills } from './skills.mjs';
|
|
15
|
-
import { copyCodexAgents } from './subagents.mjs';
|
|
16
|
-
import { projectHooks } from './hooks.mjs';
|
|
17
|
-
|
|
18
|
-
// Project every Codex artifact into targetDir. Returns the same {installed,
|
|
19
|
-
// notices, warnings} accounting shape the installer already collects, plus
|
|
20
|
-
// `owned` — the top-level Codex targets, recorded in harness.json's
|
|
21
|
-
// `owned.codex` so a later version can prune one it stops shipping.
|
|
22
|
-
export function projectCodexArtifacts({
|
|
23
|
-
targetDir,
|
|
24
|
-
bundledHarnessDir,
|
|
25
|
-
bundledClaudeMd,
|
|
26
|
-
projectHasGit,
|
|
27
|
-
priorCodexOwned = [],
|
|
28
|
-
}) {
|
|
29
|
-
const installed = [];
|
|
30
|
-
const notices = [];
|
|
31
|
-
const warnings = [];
|
|
32
|
-
const owned = [];
|
|
33
|
-
|
|
34
|
-
const collect = (result, ownedPath) => {
|
|
35
|
-
if (!result) return;
|
|
36
|
-
if (result.installed) installed.push(result.installed);
|
|
37
|
-
if (result.notice) notices.push(result.notice);
|
|
38
|
-
if (result.warning) warnings.push(result.warning);
|
|
39
|
-
owned.push(ownedPath);
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
// AGENTS.md — CLAUDE.md copied byte-for-byte + a generated Codex runtime adapter derived from
|
|
43
|
-
// the bundled harness inventory (see agents-md.mjs), with the CLAUDE.md backup-if-no-git rule.
|
|
44
|
-
collect(projectAgentsMd({ targetDir, bundledClaudeMd, bundledHarnessDir, projectHasGit }), 'AGENTS.md');
|
|
45
|
-
// .agents/skills — full-rebuilt mirror of the installed (already ship-filtered) .claude/skills.
|
|
46
|
-
collect(projectSkills({ targetDir, projectHasGit }), '.agents/skills');
|
|
47
|
-
// .codex/agents — copy the shipped, pre-authored *.toml (see docs/architecture/harness/authoring-agents.md).
|
|
48
|
-
collect(copyCodexAgents({ targetDir, bundledHarnessDir, projectHasGit }), '.codex/agents');
|
|
49
|
-
// .codex/hooks.json — Claude hooks.json filtered to type:"command".
|
|
50
|
-
collect(projectHooks({ targetDir, bundledHarnessDir }), '.codex/hooks.json');
|
|
51
|
-
|
|
52
|
-
// Prune a Codex target a prior version owned but this bundle no longer emits.
|
|
53
|
-
for (const rel of priorCodexOwned) {
|
|
54
|
-
if (owned.includes(rel)) continue;
|
|
55
|
-
const target = join(targetDir, rel);
|
|
56
|
-
if (existsSync(target)) {
|
|
57
|
-
rmSync(target, { recursive: true, force: true });
|
|
58
|
-
notices.push(`removed orphaned codex ${rel}`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return { installed, notices, warnings, owned };
|
|
63
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
// packages/cli/lib/codex/plugin-manifest.mjs
|
|
2
|
-
// Pure builders for the Codex plugin + marketplace manifests, emitted by the
|
|
3
|
-
// marketplace shipper (.github/scripts/publish.mjs) alongside the Claude ones.
|
|
4
|
-
// A Codex plugin reuses the same skills/ and hooks/ dirs as a Claude plugin — only
|
|
5
|
-
// the manifest differs (.codex-plugin/plugin.json) — plus a Codex marketplace
|
|
6
|
-
// catalog (.agents/plugins/marketplace.json). Codex also reads the legacy
|
|
7
|
-
// .claude-plugin/marketplace.json but for DISCOVERY only, so native ones are still emitted.
|
|
8
|
-
|
|
9
|
-
// The required Codex plugin.json fields + component pointers. `hasHooks` adds the
|
|
10
|
-
// hooks pointer (Codex auto-detects hooks/hooks.json, but naming it is explicit).
|
|
11
|
-
export function buildCodexPluginJson(fm, { hasHooks = false } = {}) {
|
|
12
|
-
const out = {
|
|
13
|
-
name: fm.name,
|
|
14
|
-
version: fm.version,
|
|
15
|
-
description: fm.description,
|
|
16
|
-
skills: './skills/',
|
|
17
|
-
};
|
|
18
|
-
if (hasHooks) out.hooks = './hooks/hooks.json';
|
|
19
|
-
return out;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// The repo marketplace catalog Codex reads at .agents/plugins/marketplace.json. Each plugin is a
|
|
23
|
-
// LOCAL source (source.path relative to the catalog root) carrying an explicit install policy:
|
|
24
|
-
// · installation AVAILABLE — offered, not force-installed (INSTALLED_BY_DEFAULT / NOT_AVAILABLE
|
|
25
|
-
// are the other documented tokens; these plugins are opt-in).
|
|
26
|
-
// · authentication NONE — they bundle only skills + hooks, no credentials or external auth.
|
|
27
|
-
// The first keyword (if any) seeds a category. (Enum tokens per codex-support.md → Open Questions;
|
|
28
|
-
// AVAILABLE/NONE are the correct pair for an opt-in, no-auth plugin.)
|
|
29
|
-
export function buildCodexMarketplace(plugins) {
|
|
30
|
-
return {
|
|
31
|
-
name: 'nurixlabs',
|
|
32
|
-
interface: { displayName: 'Nurix Labs' },
|
|
33
|
-
plugins: [...plugins]
|
|
34
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
35
|
-
.map((p) => ({
|
|
36
|
-
name: p.name,
|
|
37
|
-
source: { path: `./plugins/${p.name}` },
|
|
38
|
-
policy: { installation: 'AVAILABLE', authentication: 'NONE' },
|
|
39
|
-
...(p.frontmatter?.keywords?.length ? { category: p.frontmatter.keywords[0] } : {}),
|
|
40
|
-
})),
|
|
41
|
-
};
|
|
42
|
-
}
|
package/lib/codex/subagents.mjs
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
// packages/cli/lib/codex/subagents.mjs
|
|
2
|
-
// Copy the harness's pre-authored Codex subagent TOML — bundle codex/agents/*.toml,
|
|
3
|
-
// authored per docs/architecture/harness/authoring-agents.md — into the project's
|
|
4
|
-
// .codex/agents/. Full rebuild each run. The consumer needs NO Codex CLI: the TOML
|
|
5
|
-
// ships ready, so a missing local Codex never blocks an install.
|
|
6
|
-
|
|
7
|
-
import { existsSync, readdirSync, cpSync, rmSync, mkdirSync } from 'node:fs';
|
|
8
|
-
import { join } from 'node:path';
|
|
9
|
-
|
|
10
|
-
export function copyCodexAgents({ targetDir, bundledHarnessDir, projectHasGit }) {
|
|
11
|
-
const src = join(bundledHarnessDir, 'codex', 'agents');
|
|
12
|
-
if (!existsSync(src)) return null;
|
|
13
|
-
const tomls = readdirSync(src).filter((f) => f.endsWith('.toml'));
|
|
14
|
-
if (!tomls.length) return null;
|
|
15
|
-
const dest = join(targetDir, '.codex', 'agents');
|
|
16
|
-
if (existsSync(dest)) {
|
|
17
|
-
if (!projectHasGit) {
|
|
18
|
-
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
19
|
-
cpSync(dest, `${dest}.bak.${ts}`, { recursive: true });
|
|
20
|
-
}
|
|
21
|
-
rmSync(dest, { recursive: true, force: true });
|
|
22
|
-
}
|
|
23
|
-
mkdirSync(dest, { recursive: true });
|
|
24
|
-
for (const f of tomls) cpSync(join(src, f), join(dest, f), { force: true });
|
|
25
|
-
return { installed: `${tomls.length} codex agent(s) → .codex/agents/` };
|
|
26
|
-
}
|