@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.
- package/README.md +15 -14
- package/bin/etna.js +61 -19
- package/data/harness-index.json +1 -1
- package/data/harnesses/code/codex/agents/bookkeeper.toml +1 -1
- package/data/harnesses/code/harness.json +6 -2
- package/data/harnesses/code/hooks/awareness.mjs +50 -2
- package/data/harnesses/code/hooks/hooks.json +1 -1
- package/data/harnesses/code/hooks/lib/backlog-detector.mjs +82 -0
- package/data/harnesses/code/hooks/lib/records-door.mjs +54 -0
- package/data/harnesses/code/hooks/mount.mjs +470 -0
- package/data/harnesses/code/skills/documentation/SKILL.md +6 -6
- package/data/harnesses/code/skills/documentation/references/examples.md +29 -25
- package/data/harnesses/code/skills/documentation/references/style-guide.md +211 -335
- package/data/harnesses/code/skills/documentation/references/untemplated.md +2 -1
- package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +33 -17
- package/data/harnesses/code/skills/documentation/templates/doctrine.md +15 -16
- package/data/harnesses/code/skills/documentation/templates/feature.md +53 -17
- package/data/harnesses/code/skills/documentation/templates/handover.md +1 -1
- package/data/harnesses/code/skills/documentation/templates/package.md +38 -15
- package/data/harnesses/code/skills/documentation/templates/root-readme.md +16 -16
- package/data/harnesses/code/skills/documentation/templates/setup.md +1 -1
- package/data/harnesses/code/skills/documentation/templates/system.md +37 -15
- package/data/harnesses/delivery/harness.json +6 -2
- package/data/harnesses/delivery/skills/documentation/SKILL.md +6 -6
- package/data/harnesses/delivery/skills/documentation/references/examples.md +29 -25
- package/data/harnesses/delivery/skills/documentation/references/style-guide.md +211 -335
- package/data/harnesses/delivery/skills/documentation/references/untemplated.md +2 -1
- package/data/harnesses/delivery/skills/documentation/scripts/doc-lint.mjs +33 -17
- package/data/harnesses/delivery/skills/documentation/templates/doctrine.md +15 -16
- package/data/harnesses/delivery/skills/documentation/templates/feature.md +53 -17
- package/data/harnesses/delivery/skills/documentation/templates/handover.md +1 -1
- package/data/harnesses/delivery/skills/documentation/templates/package.md +38 -15
- package/data/harnesses/delivery/skills/documentation/templates/root-readme.md +16 -16
- package/data/harnesses/delivery/skills/documentation/templates/setup.md +1 -1
- package/data/harnesses/delivery/skills/documentation/templates/system.md +37 -15
- package/data/harnesses/discover/harness.json +4 -0
- package/data/harnesses/discussion/harness.json +4 -0
- package/data/harnesses/etna/harness.json +6 -2
- package/data/harnesses/etna/skills/documentation/SKILL.md +6 -6
- package/data/harnesses/etna/skills/documentation/references/examples.md +29 -25
- package/data/harnesses/etna/skills/documentation/references/style-guide.md +211 -335
- package/data/harnesses/etna/skills/documentation/references/untemplated.md +2 -1
- package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +33 -17
- package/data/harnesses/etna/skills/documentation/templates/doctrine.md +15 -16
- package/data/harnesses/etna/skills/documentation/templates/feature.md +53 -17
- package/data/harnesses/etna/skills/documentation/templates/handover.md +1 -1
- package/data/harnesses/etna/skills/documentation/templates/package.md +38 -15
- package/data/harnesses/etna/skills/documentation/templates/root-readme.md +16 -16
- package/data/harnesses/etna/skills/documentation/templates/setup.md +1 -1
- package/data/harnesses/etna/skills/documentation/templates/system.md +37 -15
- package/data/harnesses/hr/harness.json +4 -0
- package/data/harnesses/kb-agi/harness.json +4 -0
- package/lib/install/materialize.mjs +51 -23
- package/lib/install/mount.mjs +448 -0
- package/lib/install/plan.mjs +24 -29
- 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.
|
|
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 (
|
|
28
|
-
├── AGENTS.md ←
|
|
29
|
-
├── .
|
|
30
|
-
├── .prettierignore
|
|
31
|
-
├── .
|
|
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
|
-
├──
|
|
34
|
-
├──
|
|
35
|
-
├──
|
|
36
|
-
├── hooks
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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
|
package/data/harness-index.json
CHANGED
|
@@ -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` |
|
|
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
|
+
"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
|
-
"
|
|
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/
|
|
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
|
+
}
|