@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.
Files changed (62) hide show
  1. package/bin/etna.js +0 -11
  2. package/data/catalog/rules/frontend.md +1 -1
  3. package/data/catalog/rules/general.md +3 -3
  4. package/data/catalog/rules/nomenclature.md +0 -4
  5. package/data/catalog/rules/pnpm.md +1 -1
  6. package/data/catalog/rules/testing.md +1 -0
  7. package/data/harness-index.json +1 -1
  8. package/data/harnesses/code/CLAUDE.md +14 -14
  9. package/data/harnesses/code/agents/bookkeeper.md +4 -1
  10. package/data/harnesses/code/agents/maintenance.md +3 -14
  11. package/data/harnesses/code/agents/performance-review.md +1 -1
  12. package/data/harnesses/code/codex/agents/performance-review.toml +1 -1
  13. package/data/harnesses/code/harness.json +2 -2
  14. package/data/harnesses/code/hooks/awareness.mjs +5 -169
  15. package/data/harnesses/code/hooks/compliance.mjs +38 -18
  16. package/data/harnesses/code/hooks/lib/ledger.mjs +36 -13
  17. package/data/harnesses/code/hooks/lib/state.mjs +85 -1
  18. package/data/harnesses/code/hooks/lib/sweep.mjs +172 -0
  19. package/data/harnesses/code/rules/frontend.md +1 -1
  20. package/data/harnesses/code/rules/general.md +3 -3
  21. package/data/harnesses/code/rules/nomenclature.md +0 -4
  22. package/data/harnesses/code/rules/pnpm.md +1 -1
  23. package/data/harnesses/code/rules/testing.md +1 -0
  24. package/data/harnesses/code/skills/boundary/SKILL.md +1 -1
  25. package/data/harnesses/code/skills/documentation/SKILL.md +1 -1
  26. package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +1 -1
  27. package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +14 -8
  28. package/data/harnesses/code/skills/documentation/templates/plan.md +9 -16
  29. package/data/harnesses/code/skills/testing/SKILL.md +1 -2
  30. package/data/harnesses/delivery/agents/maintenance.md +3 -14
  31. package/data/harnesses/delivery/harness.json +2 -2
  32. package/data/harnesses/delivery/hooks/generate-ownership-index.mjs +3 -1
  33. package/data/harnesses/delivery/rules/nomenclature.md +0 -4
  34. package/data/harnesses/delivery/skills/documentation/SKILL.md +1 -1
  35. package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +1 -1
  36. package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +14 -8
  37. package/data/harnesses/delivery/skills/documentation/templates/plan.md +9 -16
  38. package/data/harnesses/etna/agents/maintenance.md +3 -14
  39. package/data/harnesses/etna/agents/performance-review.md +1 -1
  40. package/data/harnesses/etna/codex/agents/performance-review.toml +1 -1
  41. package/data/harnesses/etna/harness.json +2 -2
  42. package/data/harnesses/etna/hooks/generate-ownership-index.mjs +3 -1
  43. package/data/harnesses/etna/rules/general.md +3 -3
  44. package/data/harnesses/etna/rules/nomenclature.md +0 -4
  45. package/data/harnesses/etna/rules/testing.md +1 -0
  46. package/data/harnesses/etna/skills/boundary/SKILL.md +1 -1
  47. package/data/harnesses/etna/skills/documentation/SKILL.md +1 -1
  48. package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +1 -1
  49. package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +14 -8
  50. package/data/harnesses/etna/skills/documentation/templates/plan.md +9 -16
  51. package/lib/codex/agents-md.mjs +1 -35
  52. package/lib/codex/hooks.mjs +0 -29
  53. package/lib/install/materialize.mjs +4 -34
  54. package/lib/install/plan.mjs +54 -10
  55. package/package.json +3 -3
  56. package/data/harnesses/code/hooks/check-bookkeeping-clean.mjs +0 -24
  57. package/data/harnesses/code/hooks/check-changelog-presence.mjs +0 -36
  58. package/data/harnesses/code/hooks/check-completion-integrity.mjs +0 -72
  59. package/data/harnesses/code/hooks/check-ownership.mjs +0 -55
  60. package/lib/codex/index.mjs +0 -63
  61. package/lib/codex/plugin-manifest.mjs +0 -42
  62. package/lib/codex/subagents.mjs +0 -26
@@ -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, lstatSync, renameSync, readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
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
- }
@@ -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, pluginName, projectHasGit }) {
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, projectHasGit, snapshot: false });
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
  }
@@ -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. Recorded in harness.json's `owned.codex` so a later version can prune one it drops.
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
- export function planHarnessFiles(bundleDir) {
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
- const codexOwned = [];
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.19.91",
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": "b52694cf6295"
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);
@@ -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
- }
@@ -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
- }