@nurix/etna 0.20.26 → 0.20.28

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 (56) hide show
  1. package/README.md +15 -14
  2. package/bin/etna.js +61 -19
  3. package/data/harness-index.json +1 -1
  4. package/data/harnesses/code/codex/agents/bookkeeper.toml +1 -1
  5. package/data/harnesses/code/harness.json +6 -2
  6. package/data/harnesses/code/hooks/awareness.mjs +50 -2
  7. package/data/harnesses/code/hooks/hooks.json +1 -1
  8. package/data/harnesses/code/hooks/lib/backlog-detector.mjs +82 -0
  9. package/data/harnesses/code/hooks/lib/records-door.mjs +54 -0
  10. package/data/harnesses/code/hooks/mount.mjs +470 -0
  11. package/data/harnesses/code/skills/documentation/SKILL.md +6 -6
  12. package/data/harnesses/code/skills/documentation/references/examples.md +29 -25
  13. package/data/harnesses/code/skills/documentation/references/style-guide.md +211 -335
  14. package/data/harnesses/code/skills/documentation/references/untemplated.md +2 -1
  15. package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +33 -17
  16. package/data/harnesses/code/skills/documentation/templates/doctrine.md +15 -16
  17. package/data/harnesses/code/skills/documentation/templates/feature.md +53 -17
  18. package/data/harnesses/code/skills/documentation/templates/handover.md +1 -1
  19. package/data/harnesses/code/skills/documentation/templates/package.md +38 -15
  20. package/data/harnesses/code/skills/documentation/templates/root-readme.md +16 -16
  21. package/data/harnesses/code/skills/documentation/templates/setup.md +1 -1
  22. package/data/harnesses/code/skills/documentation/templates/system.md +37 -15
  23. package/data/harnesses/delivery/harness.json +6 -2
  24. package/data/harnesses/delivery/skills/documentation/SKILL.md +6 -6
  25. package/data/harnesses/delivery/skills/documentation/references/examples.md +29 -25
  26. package/data/harnesses/delivery/skills/documentation/references/style-guide.md +211 -335
  27. package/data/harnesses/delivery/skills/documentation/references/untemplated.md +2 -1
  28. package/data/harnesses/delivery/skills/documentation/scripts/doc-lint.mjs +33 -17
  29. package/data/harnesses/delivery/skills/documentation/templates/doctrine.md +15 -16
  30. package/data/harnesses/delivery/skills/documentation/templates/feature.md +53 -17
  31. package/data/harnesses/delivery/skills/documentation/templates/handover.md +1 -1
  32. package/data/harnesses/delivery/skills/documentation/templates/package.md +38 -15
  33. package/data/harnesses/delivery/skills/documentation/templates/root-readme.md +16 -16
  34. package/data/harnesses/delivery/skills/documentation/templates/setup.md +1 -1
  35. package/data/harnesses/delivery/skills/documentation/templates/system.md +37 -15
  36. package/data/harnesses/discover/harness.json +4 -0
  37. package/data/harnesses/discussion/harness.json +4 -0
  38. package/data/harnesses/etna/harness.json +6 -2
  39. package/data/harnesses/etna/skills/documentation/SKILL.md +6 -6
  40. package/data/harnesses/etna/skills/documentation/references/examples.md +29 -25
  41. package/data/harnesses/etna/skills/documentation/references/style-guide.md +211 -335
  42. package/data/harnesses/etna/skills/documentation/references/untemplated.md +2 -1
  43. package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +33 -17
  44. package/data/harnesses/etna/skills/documentation/templates/doctrine.md +15 -16
  45. package/data/harnesses/etna/skills/documentation/templates/feature.md +53 -17
  46. package/data/harnesses/etna/skills/documentation/templates/handover.md +1 -1
  47. package/data/harnesses/etna/skills/documentation/templates/package.md +38 -15
  48. package/data/harnesses/etna/skills/documentation/templates/root-readme.md +16 -16
  49. package/data/harnesses/etna/skills/documentation/templates/setup.md +1 -1
  50. package/data/harnesses/etna/skills/documentation/templates/system.md +37 -15
  51. package/data/harnesses/hr/harness.json +4 -0
  52. package/data/harnesses/kb-agi/harness.json +4 -0
  53. package/lib/install/materialize.mjs +51 -23
  54. package/lib/install/mount.mjs +448 -0
  55. package/lib/install/plan.mjs +24 -29
  56. package/package.json +6 -3
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @nurix/etna
2
2
 
3
- CLI to bootstrap a project with a Nurix harness. Copies the harness's `CLAUDE.md`, skills, agents, rules, and hooks (plus scripts, if the harness ships any) into the project so they live with the code — version-controllable, editable per project, and visible without leaving the repo.
3
+ CLI to bootstrap a project with a Nurix harness. It writes the harness in two layers. The **contract** is committed with your code: `CLAUDE.md`, `AGENTS.md`, `.claude/harness.json` (which harness, which version, where it comes from), `.claude/settings.json` and the one session-start hook. The **mounted harness** (skills, agents, rules, hooks) is gitignored, and every session start keeps it current from the harness CDN.
4
4
 
5
5
  A second install path also exists and is fully supported: `/plugin install <plugin>@nurixlabs` from inside Claude Code, which installs the harness as a plugin from the `nurixlabs/skills` marketplace. The two paths are first-class equals — pick the one that matches how you want to manage the harness. See [`docs/playbooks/harness/Readme.md`](../../docs/playbooks/harness/Readme.md#the-install-model) for the comparison.
6
6
 
@@ -24,23 +24,24 @@ After this completes (the `code` harness shown — the component folders differ
24
24
 
25
25
  ```
26
26
  your-project/
27
- ├── CLAUDE.md ← harness policy (non-git repo: existing file → CLAUDE.md.bak.<ISO>)
28
- ├── AGENTS.md ← Codex: CLAUDE.md byte-for-byte + a generated Codex runtime adapter
29
- ├── .prettierrc.json ← markdown formatting (proseWrap: never)
30
- ├── .prettierignore
31
- ├── .vscode/settings.json ← editor settings (markdown formatter on save)
27
+ ├── CLAUDE.md ← committed: harness policy (a pre-existing file moves aside as CLAUDE.bkp.md on first install)
28
+ ├── AGENTS.md ← committed: Codex — CLAUDE.md byte-for-byte + a generated Codex runtime adapter
29
+ ├── .gitignore ← gains the managed `# etna:mount:start` … `# etna:mount:end` block
30
+ ├── .prettierrc.json, .prettierignore, .vscode/settings.json ← committed project config
31
+ ├── .codex/hooks.json ← committed: Codex's SessionStart hook (runs mount.mjs)
32
+ ├── .codex/agents/ ← mounted: Codex subagents
33
+ ├── .agents/skills/ ← mounted: mirror of .claude/skills/ for Codex
32
34
  └── .claude/
33
- ├── skills/ ← boundary, brainstorm, design, documentation, test-plan, workflow-mapper
34
- ├── agents/ ← brainstorm, change-review, lld-mode, maintenance, performance-review, retro
35
- ├── rules/ ← database.md, express.md, frontend.md, general.md, nomenclature.md, pnpm.md, testing.md
36
- ├── hooks/ ← generate-ownership-index (SessionStart) + hooks.json
37
- ├── settings.json ← the hook is deep-merged in here (your existing keys preserved)
38
- └── harness.json ← {harness, name, version, files, digest, owned} — the installed-harness sentinel
35
+ ├── harness.json ← committed: {harness, version, digest, docsRoot, mount: {origin, track}, owned, …}
36
+ ├── settings.json ← committed: the hooks deep-merged in (your existing keys preserved)
37
+ ├── hooks/mount.mjs ← committed: the SessionStart hook that mounts everything else
38
+ ├── skills/ agents/ rules/ hooks/… ← mounted, gitignored by name (your own components stay tracked)
39
+ └── state/ ← machine-local: mount.json and the hooks' state, self-ignored
39
40
  ```
40
41
 
41
- Alongside the Claude tree, the installer projects the **Codex** artifacts so the repo also works under OpenAI Codex: `AGENTS.md` (above), `.agents/skills/` (a mirror of `.claude/skills/`, Codex's skill-scan path), and `.codex/agents/*.toml` + `.codex/hooks.json` (its subagents and SessionStart hook) — each surface written only when the harness ships that component kind. Nothing is written outside the project. No marketplace registration, no `enabledPlugins` entry, no global cache dependency — Claude Code picks up the project-scoped agents/skills/rules on its next session start (and Codex reads `AGENTS.md` + `.agents/` + `.codex/`), and the hooks wire in through `.claude/settings.json`.
42
+ **At every session start** `.claude/hooks/mount.mjs` mounts the version `harness.json` pins, from the machine cache (`~/.etna/cache`) or the origin (`https://cdn.nustack.io` by default). It then checks `latest.json` for a newer version. When it finds one, it downloads the version beside the running one and prints *Harness code {new} is ready (mounted {old}); restart the session to run on it*. With `--track=latest` (the default) it also moves `version` in `harness.json`; commit that change like a lockfile bump. With `--track=pinned` it only prints the notice. `--origin=<url>` points a private network at its own origin. Offline with a warm cache, the session mounts from the cache. Offline with a cold cache, it prints one line and runs on what is on disk.
42
43
 
43
- Re-run `npx --yes @nurix/etna --name=code` anytime to refresh the harness content with what the latest published package ships. Existing same-named files are overwritten, and a component the harness used to own but no longer ships is pruned; files you've added that aren't part of the harness are left alone. In a git repo the previous `CLAUDE.md` and `.claude/` are overwritten and refreshed in place (recover them with `git checkout`); outside a git repo `CLAUDE.md` is backed up to `CLAUDE.md.bak.<ISO-timestamp>` and the whole `.claude/` tree is snapshotted to `.claude.bak.<ISO-timestamp>` first. Because the new `harness.json` carries the installed `{version, digest}`, a re-run whose **major** version is ahead of your repo's stamped `harness-version` is what the reconcile (below) detects as a structural upgrade.
44
+ `npx --yes @nurix/etna --name=code` refreshes the contract and remounts from the bundle. A component absent from the manifest is pruned; files you've added that aren't part of the harness are left alone. **On a repository whose tracked set includes mounted paths**, the CLI prints the exact `git rm -r --cached -- <paths>` that adopts the mount; the files stay on disk, and you run and commit it yourself. Because `harness.json` carries the installed `{version, digest}`, a re-run whose **major** version is ahead of your repo's stamped `harness-version` is what the reconcile (below) detects as a structural upgrade.
44
45
 
45
46
  ## After install: bootstrap the harness
46
47
 
package/bin/etna.js CHANGED
@@ -1,18 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // packages/cli/bin/etna.js
3
- // `npx @nurix/etna [harness-name]` — copy a Nurix harness into the current
4
- // directory. Given a name it installs straight away; with no name (in a TTY) it
5
- // prompts you to pick one. Drops the harness's CLAUDE.md + project-root config
6
- // files (.prettierrc.json, .vscode/settings.json, …) at the project root, and
7
- // copies its skills, agents, rules, scripts, and hooks into <project>/.claude/.
8
- // Agents — the brainstorm facilitator plus the generic governance agents
9
- // (spec-mode, maintenance, change-review, performance-review) —
10
- // are vendored per-project into <project>/.claude/agents/, the same lifecycle as
11
- // skills and rules. The CLI writes only inside the target folder; nothing global.
12
- // No marketplace, no plugin enablement — files live in the project,
13
- // version-controlled and editable per project. Re-run `etna` to refresh.
14
- // Harnesses are addressed by their harness/<folder> name; several harnesses may
15
- // share one plugin.
3
+ // `npx @nurix/etna --name=<harness>` — install a Nurix harness into the current directory: the
4
+ // committed contract (CLAUDE.md, AGENTS.md, .claude/harness.json, settings.json, the mount hook,
5
+ // .codex/hooks.json, root config and seeds) plus the mounted harness, gitignored by a managed
6
+ // .gitignore block and written from the bundled payload. Outside the project it writes only the
7
+ // machine cache (~/.etna/cache). Fuller account: docs/packages/cli.md.
16
8
 
17
9
  import { existsSync, mkdirSync, copyFileSync, cpSync, rmSync } from 'node:fs';
18
10
  import { join, dirname, resolve } from 'node:path';
@@ -20,6 +12,8 @@ import { fileURLToPath } from 'node:url';
20
12
  import { spawnSync } from 'node:child_process';
21
13
  import * as p from '@clack/prompts';
22
14
  import { materializeHarness, readJson as readJsonOrThrow } from '../lib/install/materialize.mjs';
15
+ import { planHarnessFiles } from '../lib/install/plan.mjs';
16
+ import { buildManifest, warmCacheFromPlan, probeOrigin, mountSettings } from '../lib/install/mount.mjs';
23
17
  import { projectSkills } from '../lib/codex/skills.mjs';
24
18
 
25
19
  // WHERE A HARNESS'S FILES LAND is not decided here — `lib/install/materialize.mjs` owns that
@@ -112,7 +106,7 @@ async function resolveHarnessName(name, harnesses) {
112
106
 
113
107
  // Copy one harness's bundled payload into the current directory. All file ops run
114
108
  // inside a spinner; the per-item detail + any warnings are emitted after it stops.
115
- async function installHarness(harnessName, harnesses) {
109
+ async function installHarness(harnessName, harnesses, mount = {}) {
116
110
  const bundledHarnessDir = join(dataDir, 'harnesses', harnessName);
117
111
  const targetDir = process.cwd();
118
112
 
@@ -128,6 +122,7 @@ async function installHarness(harnessName, harnesses) {
128
122
  result = materializeHarness({
129
123
  bundledHarnessDir,
130
124
  targetDir,
125
+ mount,
131
126
  });
132
127
  } catch (error) {
133
128
  spin.stop(`Could not install ${harnessName}`);
@@ -136,9 +131,37 @@ async function installHarness(harnessName, harnesses) {
136
131
 
137
132
  spin.stop(`Installed ${harnessName}${versionTag}`);
138
133
 
134
+ // Warm the machine cache from the bundle so another clone on this machine mounts offline, then
135
+ // ask the origin whether a fresh clone elsewhere can mount this version.
136
+ const warnings = [...result.warnings];
137
+ const marker = readJson(join(targetDir, '.claude', 'harness.json'));
138
+ const { origin } = mountSettings(marker);
139
+ if (harnessMeta.version) {
140
+ try {
141
+ const { files, owned } = planHarnessFiles(bundledHarnessDir);
142
+ const placementVersion = `@nurix/etna@${readJson(join(pkgRoot, 'package.json')).version}`;
143
+ const manifest = buildManifest(files, {
144
+ slug: harnessName,
145
+ version: harnessMeta.version,
146
+ digest: harnessMeta.digest,
147
+ placementVersion,
148
+ owned,
149
+ });
150
+ warmCacheFromPlan({ files, manifest });
151
+ } catch (error) {
152
+ warnings.push(`Machine cache not warmed: ${error.message}`);
153
+ }
154
+ const published = await probeOrigin({ origin, slug: harnessName, version: harnessMeta.version });
155
+ if (published === 'unreachable') {
156
+ warnings.push(`${origin} is unreachable — mounted from the bundled payload; a fresh clone elsewhere mounts once it answers.`);
157
+ } else if (published === 'unpublished') {
158
+ warnings.push(`${harnessName} ${harnessMeta.version} is not published at ${origin} — a fresh clone elsewhere cannot mount it yet.`);
159
+ }
160
+ }
161
+
139
162
  p.note(result.installed.join('\n'), 'Written');
140
163
  for (const notice of result.notices) p.log.info(notice);
141
- for (const warning of result.warnings) p.log.warn(warning);
164
+ for (const warning of warnings) p.log.warn(warning);
142
165
 
143
166
  p.outro(
144
167
  `${harnessName}${versionTag} ready in ${targetDir}. ` +
@@ -152,6 +175,7 @@ async function installHarness(harnessName, harnesses) {
152
175
  function parseArgs(args) {
153
176
  let name = null;
154
177
  let help = false;
178
+ const mount = {};
155
179
  let positional = null;
156
180
  const components = [];
157
181
  const takeValue = (a, flag, i) =>
@@ -161,6 +185,15 @@ function parseArgs(args) {
161
185
  if (a === '-h' || a === '--help') help = true;
162
186
  else if (a.startsWith('--name=')) name = a.slice('--name='.length);
163
187
  else if (a === '--name') name = args[++i] ?? '';
188
+ else if (a === '--track' || a.startsWith('--track=')) {
189
+ const [value, next] = takeValue(a, '--track', i);
190
+ i = next;
191
+ mount.track = value;
192
+ } else if (a === '--origin' || a.startsWith('--origin=')) {
193
+ const [value, next] = takeValue(a, '--origin', i);
194
+ i = next;
195
+ mount.origin = value.replace(/\/+$/, '');
196
+ }
164
197
  else if (a === '--rule' || a.startsWith('--rule=')) {
165
198
  const [value, next] = takeValue(a, '--rule', i);
166
199
  i = next;
@@ -171,7 +204,7 @@ function parseArgs(args) {
171
204
  if (value) components.push({ kind: 'skills', name: value });
172
205
  } else if (!a.startsWith('-') && positional === null) positional = a;
173
206
  }
174
- return { name, help, positional, components };
207
+ return { name, help, positional, components, mount };
175
208
  }
176
209
 
177
210
  // Install one catalog component into the current directory — a rule as
@@ -257,7 +290,7 @@ function selfUpdateOrReexec() {
257
290
  async function main(args) {
258
291
  const idx = loadHarnessIndex();
259
292
  const harnesses = idx.harnesses || {};
260
- const { name, help, positional, components } = parseArgs(args);
293
+ const { name, help, positional, components, mount } = parseArgs(args);
261
294
 
262
295
  if (help) {
263
296
  p.intro('@nurix/etna — Nurix harness installer');
@@ -266,7 +299,9 @@ async function main(args) {
266
299
  ' npx @nurix/etna pick a harness from the menu\n' +
267
300
  ' npx @nurix/etna --name=<harness> install that harness directly\n' +
268
301
  ' npx @nurix/etna --rule=<name> install one catalog rule\n' +
269
- ' npx @nurix/etna --skill=<name> install one subscribed catalog skill',
302
+ ' npx @nurix/etna --skill=<name> install one subscribed catalog skill\n' +
303
+ ' --track=latest|pinned whether sessions move the pin to a newer version (default latest)\n' +
304
+ ' --origin=<url> where sessions fetch the harness from (default https://cdn.nustack.io)',
270
305
  );
271
306
  printHarnessList(harnesses);
272
307
  const index = readJson(join(dataDir, 'catalog', 'index.json'));
@@ -282,6 +317,13 @@ async function main(args) {
282
317
 
283
318
  p.intro('@nurix/etna — Nurix harness installer');
284
319
 
320
+ if (mount.track !== undefined && !['latest', 'pinned'].includes(mount.track)) {
321
+ die(`--track takes latest or pinned, not '${mount.track}'.`);
322
+ }
323
+ if (mount.origin !== undefined && !/^https?:\/\/\S+$/.test(mount.origin)) {
324
+ die(`--origin takes an http(s) URL, not '${mount.origin}'.`);
325
+ }
326
+
285
327
  // A component install is standalone and skips the harness path entirely: it writes one
286
328
  // file or folder and records no ownership, so it needs no bundle, no manifest, and no
287
329
  // prune pass. Combining it with --name would make the ordering load-bearing (the harness
@@ -325,7 +367,7 @@ async function main(args) {
325
367
  const harnessName = await resolveHarnessName(requested, harnesses);
326
368
  if (!harnessName) return; // user cancelled the interactive pick
327
369
 
328
- await installHarness(harnessName, harnesses);
370
+ await installHarness(harnessName, harnesses, mount);
329
371
  }
330
372
 
331
373
  // Before anything else: if a newer @nurix/etna is published, re-exec it (so a stale
@@ -1,5 +1,5 @@
1
1
  {
2
- "fingerprint": "9db89d92a35b",
2
+ "fingerprint": "47047f5258d8",
3
3
  "harnesses": {
4
4
  "code": {
5
5
  "plugin": "code",
@@ -18,7 +18,7 @@ Ground every edit in this ladder, in order. Never invent content; a fact you can
18
18
  | `dead-glob` | read the owning doc, find where the files went (`git log --follow`, glob search), update its `owns:` frontmatter |
19
19
  | `header-missing` | add the file-path header comment in the file's native comment syntax |
20
20
  | `doc-lint` | fix the finding, or add `<!-- doc-lint-disable: id -->` with a reason beside it when the flagged text is deliberate |
21
- | `index-row-missing` | add the doc's row to `docs/README.md` |
21
+ | `index-row-missing` | a retired kind — delete the row. An index names folders, never files; a doc is found by `ls` and by the ownership index, not by a README row |
22
22
  | `doc-implicated` | read the diff of the named source file (`git diff` / `git log -p`), and update the owning doc **only where the diff contradicts documented behavior**; if nothing documented changed, the row's resolution is deleting it |
23
23
 
24
24
  2. **`note:` rows — expand strictly from the stated context.** The one-line context after the area is everything the session chose to hand you; land it in the destination the row names (`lands-in:` an owning doc, the backlog, or memory), written in that document's genre.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "code",
3
3
  "name": "code",
4
- "version": "3.19.4",
4
+ "version": "3.19.6",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
@@ -25,5 +25,9 @@
25
25
  }
26
26
  ],
27
27
  "docsRoot": "docs",
28
- "digest": "f3f36722fca1"
28
+ "mount": {
29
+ "origin": "https://cdn.nustack.io",
30
+ "track": "latest"
31
+ },
32
+ "digest": "761c9744d17b"
29
33
  }
@@ -2,11 +2,12 @@
2
2
  // SessionStart: inject the session context block, the ownership index, and a budget-capped drift
3
3
  // banner (full report → .claude/state/drift-report.md). PostToolUse (Edit|Write|MultiEdit|Bash):
4
4
  // the silent detector — record file→doc touches, append machine-recomputable findings as auto:
5
- // rows to docs/operations/bookkeeping.md. Never narrates into the session, never blocks, exits 0
5
+ // rows to docs/operations/bookkeeping.md. Both events emit backlog rows through the records door. Never narrates into the session, never blocks, exits 0
6
6
  // on every path: everything here is a convenience, absent on any failure.
7
7
 
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
+ import os from "node:os";
10
11
  import { spawnSync } from "node:child_process";
11
12
  import {
12
13
  readStdin,
@@ -52,6 +53,7 @@ import { appendEvent } from "./lib/session-log.mjs";
52
53
  import { wallsAllowlist } from "./lib/graph-store.mjs";
53
54
  import { exitWallsAllowlist } from "./lib/exit-store.mjs";
54
55
  import { wallEnabled } from "./lib/walls.mjs";
56
+ import { detectBacklog } from "./lib/backlog-detector.mjs";
55
57
 
56
58
  // Whether anything will ever read a row: a run whose allowlist switches off both walls the queue feeds —
57
59
  // the Stop wall and the commit wall — has nobody to drain it, so its rows would only ride the run's output
@@ -140,6 +142,42 @@ function toolsLines(projectDir) {
140
142
  return out;
141
143
  }
142
144
 
145
+ // Versions staged in the machine cache for this harness (the mount hook writes them there).
146
+ function stagedVersions(slug) {
147
+ const root = process.env.ETNA_CACHE_DIR || path.join(os.homedir(), ".etna", "cache");
148
+ try {
149
+ return fs.readdirSync(path.join(root, slug)).filter((v) => fs.existsSync(path.join(root, slug, v, "manifest.json")));
150
+ } catch (e) {
151
+ return [];
152
+ }
153
+ }
154
+
155
+ function newerThan(a, b) {
156
+ const left = String(a).split(".").map((n) => parseInt(n, 10) || 0);
157
+ const right = String(b).split(".").map((n) => parseInt(n, 10) || 0);
158
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
159
+ if ((left[i] || 0) !== (right[i] || 0)) return (left[i] || 0) > (right[i] || 0);
160
+ }
161
+ return false;
162
+ }
163
+
164
+ // The mounted version and digest from state/mount.json, and a newer version staged beside it.
165
+ function mountSegment(projectDir, marker) {
166
+ let mounted = null;
167
+ try {
168
+ mounted = JSON.parse(readSafe(path.join(projectDir, ".claude", "state", "mount.json")));
169
+ } catch (e) {
170
+ mounted = null;
171
+ }
172
+ if (!mounted || !mounted.version) return " · not mounted";
173
+ const digest = mounted.digest ? ` (${mounted.digest})` : "";
174
+ const staged = stagedVersions(marker.harness || "")
175
+ .filter((v) => newerThan(v, mounted.version))
176
+ .sort((a, b) => (newerThan(a, b) ? -1 : 1))[0];
177
+ const waiting = staged ? ` · ${staged} staged — restart the session to run on it` : "";
178
+ return ` · mounted ${mounted.version}${digest}${waiting}`;
179
+ }
180
+
143
181
  function harnessLine(projectDir) {
144
182
  let marker = null;
145
183
  try {
@@ -157,7 +195,7 @@ function harnessLine(projectDir) {
157
195
  : stamped
158
196
  ? `stamped ${stamped} — behind: run maintenance`
159
197
  : "unstamped — run maintenance";
160
- return `Harness: installed ${marker.version} · ${root}/structure.md ${state}`;
198
+ return `Harness: installed ${marker.version}${mountSegment(projectDir, marker)} · ${root}/structure.md ${state}`;
161
199
  }
162
200
 
163
201
  function memoryLine(projectDir) {
@@ -575,6 +613,15 @@ function postToolUse(projectDir, payload) {
575
613
  if (rows.length > 0 && queueHasReader(projectDir)) appendRows(projectDir, rows);
576
614
  }
577
615
 
616
+ // Backlog rows leave through the records door as they are written; silent, and never fatal.
617
+ function observeBacklog(projectDir) {
618
+ try {
619
+ detectBacklog(projectDir);
620
+ } catch (e) {
621
+ /* the door is a convenience; the committed file stays the record */
622
+ }
623
+ }
624
+
578
625
  // ---------------------------------------------------------------------------
579
626
 
580
627
  function main() {
@@ -589,6 +636,7 @@ function main() {
589
636
  const event = payload.hook_event_name || "SessionStart";
590
637
  if (event === "PostToolUse") postToolUse(projectDir, payload);
591
638
  else sessionStart(projectDir, payload);
639
+ observeBacklog(projectDir);
592
640
  }
593
641
 
594
642
  try {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "hooks": {
10
10
  "SessionStart": [
11
- { "matcher": "", "hooks": [ { "type": "command", "command": "f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/awareness.mjs\"; [ -f \"$f\" ] || exit 0; node \"$f\"", "timeout": 10 } ] }
11
+ { "matcher": "", "hooks": [ { "type": "command", "command": "f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/mount.mjs\"; [ -f \"$f\" ] || exit 0; node \"$f\"", "timeout": 15 } ] }
12
12
  ],
13
13
  "PostToolUse": [
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 } ] }
@@ -0,0 +1,82 @@
1
+ // harness/code/hooks/lib/backlog-detector.mjs — rows added to or removed from the backlog since the
2
+ // last observation, emitted through the records door as `backlog` and `backlog.remove`. The last
3
+ // observation lives in .claude/state/backlog-seen.json; an unchanged mtime and size, or an unchanged
4
+ // hash, skips the parse. The row grammar mirrors NuStack's backlogRow.ts, anchor included.
5
+ // Fuller account: docs/harness/code/hooks.md § lib/backlog-detector.mjs.
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { createHash } from "node:crypto";
10
+ import { docsRoot } from "./repo.mjs";
11
+ import { stateDir } from "./state.mjs";
12
+ import { emitRecord } from "./records-door.mjs";
13
+
14
+ // FNV-1a 64 over UTF-16 code units, first 8 hex — the same anchor NuStack's parser gives a row.
15
+ export function anchorOf(text) {
16
+ let hash = 0xcbf29ce484222325n;
17
+ for (let i = 0; i < text.length; i += 1) {
18
+ hash ^= BigInt(text.charCodeAt(i));
19
+ hash = (hash * 0x100000001b3n) & 0xffffffffffffffffn;
20
+ }
21
+ return hash.toString(16).padStart(16, "0").slice(0, 8);
22
+ }
23
+
24
+ // Rows of `- **[P1]** text — discovered by X` under their `## Section` headings; template sections skipped.
25
+ export function parseBacklog(body) {
26
+ const rows = [];
27
+ let section = "Open";
28
+ for (const line of String(body || "").split("\n")) {
29
+ const heading = /^## (.+)$/.exec(line);
30
+ if (heading) {
31
+ section = heading[1].trim();
32
+ continue;
33
+ }
34
+ const match = /^- \*\*\[(P[0-3])\]\*\*\s*(.*)$/.exec(line);
35
+ if (!match || /^(format|grammar|template)$/i.test(section)) continue;
36
+ const text = match[2].trim();
37
+ const discovered = /—\s*discovered (?:by|while|during)\s+(.+?)\s*$/.exec(text);
38
+ rows.push({ anchor: anchorOf(text), priority: match[1], text, discoveredBy: discovered ? discovered[1] : null, section });
39
+ }
40
+ return rows;
41
+ }
42
+
43
+ export function backlogFile(projectDir) {
44
+ return path.join(projectDir, docsRoot(projectDir), "history", "backlog.md");
45
+ }
46
+
47
+ // 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
+ export function detectBacklog(projectDir, { emit = emitRecord, parse = parseBacklog } = {}) {
50
+ const file = backlogFile(projectDir);
51
+ let stat;
52
+ try {
53
+ stat = fs.statSync(file);
54
+ } catch (e) {
55
+ return null;
56
+ }
57
+ const seenFile = path.join(stateDir(projectDir), "backlog-seen.json");
58
+ let seen = null;
59
+ try {
60
+ seen = JSON.parse(fs.readFileSync(seenFile, "utf8"));
61
+ } catch (e) {
62
+ seen = null;
63
+ }
64
+ if (seen && seen.mtimeMs === stat.mtimeMs && seen.size === stat.size) return null;
65
+ const text = fs.readFileSync(file, "utf8");
66
+ const hash = createHash("sha256").update(text).digest("hex");
67
+ const write = (rows) =>
68
+ fs.writeFileSync(seenFile, JSON.stringify({ mtimeMs: stat.mtimeMs, size: stat.size, hash, rows }) + "\n");
69
+ if (seen && seen.hash === hash) {
70
+ write(seen.rows || {});
71
+ return null;
72
+ }
73
+ const now = {};
74
+ for (const row of parse(text)) now[row.anchor] = row;
75
+ const prior = (seen && seen.rows) || {};
76
+ const added = Object.keys(now).filter((anchor) => !prior[anchor]).map((anchor) => now[anchor]);
77
+ const removed = Object.keys(prior).filter((anchor) => !now[anchor]).map((anchor) => prior[anchor]);
78
+ if (added.length) emit("backlog", added, { projectDir });
79
+ if (removed.length) emit("backlog.remove", removed, { projectDir });
80
+ write(now);
81
+ return { added, removed };
82
+ }
@@ -0,0 +1,54 @@
1
+ // harness/code/hooks/lib/records-door.mjs — the hooks' side of the records door. A hook never holds a
2
+ // credential: it hands rows to `nustack records emit`, which climbs the door's rungs, and when the tool
3
+ // is absent or its call fails it appends them to .claude/state/records/<kind>.jsonl, where the next
4
+ // session finds them. Never throws. Fuller account: docs/harness/code/hooks.md § lib/records-door.mjs.
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+ import { stateDir } from "./state.mjs";
10
+
11
+ // The first executable named `name` on the PATH, or null.
12
+ export function onPath(name, pathEnv = process.env.PATH || "") {
13
+ for (const dir of pathEnv.split(path.delimiter).filter(Boolean)) {
14
+ const candidate = path.join(dir, name);
15
+ try {
16
+ fs.accessSync(candidate, fs.constants.X_OK);
17
+ if (fs.statSync(candidate).isFile()) return candidate;
18
+ } catch (e) {
19
+ /* not here */
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+
25
+ function appendLocal(projectDir, kind, rows) {
26
+ const dir = path.join(stateDir(projectDir), "records");
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ const at = new Date().toISOString();
29
+ fs.appendFileSync(path.join(dir, `${kind}.jsonl`), rows.map((row) => JSON.stringify({ kind, row, at }) + "\n").join(""));
30
+ }
31
+
32
+ // Emits rows of one kind: through `nustack records emit --kind <kind> --json` when the tool is on the
33
+ // PATH and answers 0, else to the rung-4 file. Returns "door" or "file" (or "none" when nothing landed).
34
+ export function emitRecord(kind, rows, { projectDir, env = process.env, timeoutMs = 5000 } = {}) {
35
+ const list = (Array.isArray(rows) ? rows : [rows]).filter(Boolean);
36
+ if (list.length === 0) return "none";
37
+ const binary = onPath("nustack", env.PATH || "");
38
+ if (binary) {
39
+ const res = spawnSync(binary, ["records", "emit", "--kind", kind, "--json"], {
40
+ cwd: projectDir,
41
+ input: JSON.stringify({ kind, rows: list }),
42
+ encoding: "utf8",
43
+ env,
44
+ timeout: timeoutMs,
45
+ });
46
+ if (!res.error && res.status === 0) return "door";
47
+ }
48
+ try {
49
+ appendLocal(projectDir, kind, list);
50
+ return "file";
51
+ } catch (e) {
52
+ return "none";
53
+ }
54
+ }