@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
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
// harness/code/hooks/mount.mjs
|
|
2
|
+
// The one SessionStart hook: mount the pinned harness version, check latest.json for a newer one,
|
|
3
|
+
// then run awareness.mjs after the tree is settled. Self-contained on purpose — lib/ is itself a
|
|
4
|
+
// mounted path a bare clone lacks — so the shared core below is a byte copy of
|
|
5
|
+
// packages/cli/lib/install/mount.mjs, pinned equal by tests/mount.test.mjs. Never blocks: exit 0 always.
|
|
6
|
+
// Fuller account: docs/harness/code/hooks.md § mount.mjs.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
statSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { basename, dirname, join } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
// --- shared mount core: start ---
|
|
25
|
+
const DEFAULT_ORIGIN = 'https://cdn.nustack.io';
|
|
26
|
+
const MOUNT_BLOCK_START = '# etna:mount:start';
|
|
27
|
+
const MOUNT_BLOCK_END = '# etna:mount:end';
|
|
28
|
+
const MOUNTED_CLAUDE_SUBDIRS = ['skills', 'agents', 'rules', 'scripts', 'hooks'];
|
|
29
|
+
const CONTRACT_NEGATIONS = [
|
|
30
|
+
'!.claude/harness.json',
|
|
31
|
+
'!.claude/settings.json',
|
|
32
|
+
'!.claude/hooks/',
|
|
33
|
+
'!.claude/hooks/mount.mjs',
|
|
34
|
+
'!.codex/hooks.json',
|
|
35
|
+
];
|
|
36
|
+
const FETCH_CONCURRENCY = 8;
|
|
37
|
+
const CAPABILITY_GATES = { '.claude/rules/cloudflare.md': 'worker' };
|
|
38
|
+
|
|
39
|
+
function sha256Of(bytes) {
|
|
40
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The machine cache every repository on the machine shares; ETNA_CACHE_DIR relocates it.
|
|
44
|
+
function cacheRoot(env = process.env) {
|
|
45
|
+
return env.ETNA_CACHE_DIR || join(homedir(), '.etna', 'cache');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// True for a path the mount owns; the contract layer (the committed half) answers false.
|
|
49
|
+
function isMountedPath(path) {
|
|
50
|
+
if (path === '.claude/hooks/mount.mjs' || path === '.codex/hooks.json') return false;
|
|
51
|
+
const parts = path.split('/');
|
|
52
|
+
if (parts[0] === '.claude') return parts.length > 2 && MOUNTED_CLAUDE_SUBDIRS.includes(parts[1]);
|
|
53
|
+
return (parts[0] === '.agents' || parts[0] === '.codex') && parts.length > 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Numeric dotted-version order: negative, zero or positive, like a sort comparator.
|
|
57
|
+
function compareVersions(a, b) {
|
|
58
|
+
const left = String(a).split('.').map((part) => parseInt(part, 10) || 0);
|
|
59
|
+
const right = String(b).split('.').map((part) => parseInt(part, 10) || 0);
|
|
60
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
61
|
+
const diff = (left[i] || 0) - (right[i] || 0);
|
|
62
|
+
if (diff !== 0) return diff;
|
|
63
|
+
}
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readJsonFile(file) {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Temp file then rename, so a concurrent reader sees the old file or the new one, never a torn one.
|
|
76
|
+
function writeAtomic(file, bytes) {
|
|
77
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
78
|
+
const temp = join(dirname(file), `.${basename(file)}.mount-${process.pid}`);
|
|
79
|
+
writeFileSync(temp, bytes);
|
|
80
|
+
renameSync(temp, file);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function fetchBytes(url, fetchImpl, timeoutMs) {
|
|
84
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
85
|
+
if (!response.ok) throw new Error(`GET ${url} answered ${response.status}`);
|
|
86
|
+
return Buffer.from(await response.arrayBuffer());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Runs task over items with a bounded number in flight.
|
|
90
|
+
async function eachLimited(items, task) {
|
|
91
|
+
let next = 0;
|
|
92
|
+
const worker = async () => {
|
|
93
|
+
while (next < items.length) {
|
|
94
|
+
const item = items[next];
|
|
95
|
+
next += 1;
|
|
96
|
+
await task(item);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
await Promise.all(Array.from({ length: Math.min(FETCH_CONCURRENCY, items.length) }, worker));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The version manifest, from the machine cache when present, else fetched and cached.
|
|
103
|
+
async function resolveManifest({ origin, slug, version, cacheDir, fetchImpl, timeoutMs }) {
|
|
104
|
+
const cached = join(cacheDir, slug, version, 'manifest.json');
|
|
105
|
+
if (existsSync(cached)) return { manifest: JSON.parse(readFileSync(cached, 'utf8')), source: 'cache' };
|
|
106
|
+
const bytes = await fetchBytes(`${origin}/harness/${slug}/${version}/manifest.json`, fetchImpl, timeoutMs);
|
|
107
|
+
const manifest = JSON.parse(bytes.toString('utf8'));
|
|
108
|
+
writeAtomic(cached, bytes);
|
|
109
|
+
return { manifest, source: 'origin' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// One file's bytes by sha256: the object cache first, else fetched, verified, and cached.
|
|
113
|
+
async function readObject({ origin, entry, cacheDir, fetchImpl, timeoutMs }) {
|
|
114
|
+
const file = join(cacheDir, 'objects', entry.sha256);
|
|
115
|
+
if (existsSync(file)) {
|
|
116
|
+
const bytes = readFileSync(file);
|
|
117
|
+
if (sha256Of(bytes) === entry.sha256) return bytes;
|
|
118
|
+
}
|
|
119
|
+
const bytes = await fetchBytes(`${origin}/${entry.key}`, fetchImpl, timeoutMs);
|
|
120
|
+
if (sha256Of(bytes) !== entry.sha256) throw new Error(`${entry.path}: fetched bytes do not match the manifest`);
|
|
121
|
+
writeAtomic(file, bytes);
|
|
122
|
+
return bytes;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function mountedEntries(manifest) {
|
|
126
|
+
return (manifest.files || []).filter(
|
|
127
|
+
(entry) => (entry.policy === undefined || entry.policy === 'mount') && isMountedPath(entry.path),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A Worker's config at the root, or one or two directory levels down (node_modules and dot dirs skipped).
|
|
132
|
+
function hasWorkerConfig(projectDir) {
|
|
133
|
+
const names = ['wrangler.jsonc', 'wrangler.toml'];
|
|
134
|
+
const search = (dir, depth) => {
|
|
135
|
+
if (names.some((name) => existsSync(join(dir, name)))) return true;
|
|
136
|
+
if (depth === 0) return false;
|
|
137
|
+
let entries = [];
|
|
138
|
+
try {
|
|
139
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return entries.some(
|
|
144
|
+
(entry) => entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.') && search(join(dir, entry.name), depth - 1),
|
|
145
|
+
);
|
|
146
|
+
};
|
|
147
|
+
return search(projectDir, 2);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The mounted entries this project's capabilities admit; a gated rule mounts only where its capability is.
|
|
151
|
+
function admittedEntries(projectDir, entries) {
|
|
152
|
+
const capabilities = { worker: null };
|
|
153
|
+
return entries.filter((entry) => {
|
|
154
|
+
const gate = entry.requires || CAPABILITY_GATES[entry.path];
|
|
155
|
+
if (!gate) return true;
|
|
156
|
+
if (capabilities[gate] === null) capabilities[gate] = gate === 'worker' ? hasWorkerConfig(projectDir) : false;
|
|
157
|
+
return capabilities[gate] === true;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function filesBelow(root, rel) {
|
|
162
|
+
const full = join(root, rel);
|
|
163
|
+
let stat;
|
|
164
|
+
try {
|
|
165
|
+
stat = statSync(full);
|
|
166
|
+
} catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
if (!stat.isDirectory()) return [rel];
|
|
170
|
+
return readdirSync(full).flatMap((name) => filesBelow(root, `${rel}/${name}`));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// On-disk files the mount may delete: inside owned component names, and the hooks tree bar mount.mjs.
|
|
174
|
+
function prunableFiles(projectDir, owned) {
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const sub of ['skills', 'agents', 'rules', 'scripts']) {
|
|
177
|
+
for (const name of owned[sub] || []) out.push(...filesBelow(projectDir, `.claude/${sub}/${name}`));
|
|
178
|
+
}
|
|
179
|
+
for (const rel of filesBelow(projectDir, '.claude/hooks')) if (rel !== '.claude/hooks/mount.mjs') out.push(rel);
|
|
180
|
+
for (const name of owned.skills || []) out.push(...filesBelow(projectDir, `.agents/skills/${name}`));
|
|
181
|
+
for (const name of owned.agents || []) {
|
|
182
|
+
out.push(...filesBelow(projectDir, `.codex/agents/${name.replace(/\.md$/, '')}.toml`));
|
|
183
|
+
}
|
|
184
|
+
return out.filter((rel) => rel !== '.claude/hooks');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function unionOwned(...maps) {
|
|
188
|
+
const out = {};
|
|
189
|
+
for (const map of maps) {
|
|
190
|
+
for (const [sub, names] of Object.entries(map || {})) out[sub] = [...new Set([...(out[sub] || []), ...names])];
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function removeEmptyParents(projectDir, rel) {
|
|
196
|
+
let dir = dirname(rel);
|
|
197
|
+
while (dir.split('/').length > 2) {
|
|
198
|
+
const full = join(projectDir, dir);
|
|
199
|
+
try {
|
|
200
|
+
if (readdirSync(full).length > 0) return;
|
|
201
|
+
rmSync(full, { recursive: true, force: true });
|
|
202
|
+
} catch {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
dir = dirname(dir);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Writes the manifest's mounted files that differ on disk, then deletes owned names the manifest omits.
|
|
210
|
+
async function mountFromManifest({ projectDir, manifest, origin, cacheDir, fetchImpl, timeoutMs, priorOwned }) {
|
|
211
|
+
const all = mountedEntries(manifest);
|
|
212
|
+
const entries = admittedEntries(projectDir, all);
|
|
213
|
+
const listed = new Set(entries.map((entry) => entry.path));
|
|
214
|
+
const written = [];
|
|
215
|
+
const failed = [];
|
|
216
|
+
const removed = [];
|
|
217
|
+
const stale = entries.filter((entry) => {
|
|
218
|
+
const dest = join(projectDir, entry.path);
|
|
219
|
+
return !(existsSync(dest) && sha256Of(readFileSync(dest)) === entry.sha256);
|
|
220
|
+
});
|
|
221
|
+
await eachLimited(stale, async (entry) => {
|
|
222
|
+
try {
|
|
223
|
+
writeAtomic(join(projectDir, entry.path), await readObject({ origin, entry, cacheDir, fetchImpl, timeoutMs }));
|
|
224
|
+
written.push(entry.path);
|
|
225
|
+
} catch {
|
|
226
|
+
failed.push(entry.path);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
for (const rel of prunableFiles(projectDir, unionOwned(manifest.owned, priorOwned))) {
|
|
230
|
+
if (listed.has(rel)) continue;
|
|
231
|
+
rmSync(join(projectDir, rel), { force: true });
|
|
232
|
+
removeEmptyParents(projectDir, rel);
|
|
233
|
+
removed.push(rel);
|
|
234
|
+
}
|
|
235
|
+
return { written: written.sort(), failed: failed.sort(), removed, mountedPaths: all.map((entry) => entry.path) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The block's lines: contract negations, then the mounted top-level names and the state folder.
|
|
239
|
+
function ignoreBlockLines(mountedPaths) {
|
|
240
|
+
const names = new Set(['.claude/state/']);
|
|
241
|
+
for (const path of mountedPaths) {
|
|
242
|
+
const parts = path.split('/');
|
|
243
|
+
if (parts[0] === '.claude') names.add(parts.length > 3 ? `${parts.slice(0, 3).join('/')}/` : path);
|
|
244
|
+
else names.add(parts.length > 2 ? `${parts[0]}/${parts[1]}/` : path);
|
|
245
|
+
}
|
|
246
|
+
return [
|
|
247
|
+
MOUNT_BLOCK_START,
|
|
248
|
+
'# Managed by etna: the mounted harness, rewritten on every mount. Edit outside this block.',
|
|
249
|
+
...CONTRACT_NEGATIONS,
|
|
250
|
+
...[...names].sort(),
|
|
251
|
+
MOUNT_BLOCK_END,
|
|
252
|
+
];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Rewrites the managed block at the end of .gitignore; a bare `.claude` line becomes `.claude/*`.
|
|
256
|
+
function writeIgnoreBlock(projectDir, mountedPaths) {
|
|
257
|
+
const file = join(projectDir, '.gitignore');
|
|
258
|
+
const before = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
259
|
+
const kept = [];
|
|
260
|
+
let inBlock = false;
|
|
261
|
+
for (const line of before.split('\n')) {
|
|
262
|
+
if (line.trim() === MOUNT_BLOCK_START) inBlock = true;
|
|
263
|
+
else if (inBlock) inBlock = line.trim() !== MOUNT_BLOCK_END;
|
|
264
|
+
else {
|
|
265
|
+
const bare = /^(\/?)\.claude\/?\s*$/.exec(line);
|
|
266
|
+
kept.push(bare ? `${bare[1]}.claude/*` : line);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
while (kept.length && kept[kept.length - 1].trim() === '') kept.pop();
|
|
270
|
+
const block = ignoreBlockLines(mountedPaths).join('\n');
|
|
271
|
+
const after = (kept.length ? `${kept.join('\n')}\n\n` : '') + `${block}\n`;
|
|
272
|
+
if (after === before) return false;
|
|
273
|
+
writeAtomic(file, after);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Records what is mounted, beside the folder's self-ignoring `*` .gitignore.
|
|
278
|
+
function recordMount(projectDir, record) {
|
|
279
|
+
const dir = join(projectDir, '.claude', 'state');
|
|
280
|
+
mkdirSync(dir, { recursive: true });
|
|
281
|
+
if (!existsSync(join(dir, '.gitignore'))) writeFileSync(join(dir, '.gitignore'), '*\n');
|
|
282
|
+
const body = { ...record, mountedAt: new Date().toISOString() };
|
|
283
|
+
writeAtomic(join(dir, 'mount.json'), `${JSON.stringify(body, null, 2)}\n`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function mountSettings(marker) {
|
|
287
|
+
const mount = (marker && marker.mount) || {};
|
|
288
|
+
return {
|
|
289
|
+
origin: String(mount.origin || DEFAULT_ORIGIN).replace(/\/+$/, ''),
|
|
290
|
+
track: mount.track === 'pinned' ? 'pinned' : 'latest',
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Mounts the pinned version unless the mount record already names it.
|
|
295
|
+
async function mountHarness({ projectDir, cacheDir = cacheRoot(), fetchImpl = globalThis.fetch, timeoutMs = 5000, force = false }) {
|
|
296
|
+
const marker = readJsonFile(join(projectDir, '.claude', 'harness.json'));
|
|
297
|
+
if (!marker || !marker.harness || !marker.version) return { status: 'unmarked' };
|
|
298
|
+
const { origin } = mountSettings(marker);
|
|
299
|
+
const slug = marker.harness;
|
|
300
|
+
const version = marker.version;
|
|
301
|
+
const prior = readJsonFile(join(projectDir, '.claude', 'state', 'mount.json'));
|
|
302
|
+
if (!force && prior && prior.version === version) return { status: 'current', slug, version, digest: prior.digest };
|
|
303
|
+
let resolved;
|
|
304
|
+
try {
|
|
305
|
+
resolved = await resolveManifest({ origin, slug, version, cacheDir, fetchImpl, timeoutMs });
|
|
306
|
+
} catch (error) {
|
|
307
|
+
return { status: 'unavailable', slug, version, origin, reason: error.message };
|
|
308
|
+
}
|
|
309
|
+
const { manifest, source } = resolved;
|
|
310
|
+
const result = await mountFromManifest({
|
|
311
|
+
projectDir,
|
|
312
|
+
manifest,
|
|
313
|
+
origin,
|
|
314
|
+
cacheDir,
|
|
315
|
+
fetchImpl,
|
|
316
|
+
timeoutMs,
|
|
317
|
+
priorOwned: prior && prior.owned,
|
|
318
|
+
});
|
|
319
|
+
writeIgnoreBlock(projectDir, result.mountedPaths);
|
|
320
|
+
if (result.failed.length === 0) {
|
|
321
|
+
recordMount(projectDir, { version, digest: manifest.digest || null, owned: manifest.owned || null });
|
|
322
|
+
}
|
|
323
|
+
return { status: result.failed.length ? 'partial' : 'mounted', slug, version, source, ...result };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function readLatest({ origin, slug, fetchImpl, timeoutMs }) {
|
|
327
|
+
return JSON.parse((await fetchBytes(`${origin}/harness/${slug}/latest.json`, fetchImpl, timeoutMs)).toString('utf8'));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Stages a version into the cache, the manifest verified against the pointer and written last.
|
|
331
|
+
async function stageVersion({ origin, slug, latest, cacheDir, fetchImpl, timeoutMs }) {
|
|
332
|
+
const bytes = await fetchBytes(`${origin}/${latest.manifest}`, fetchImpl, timeoutMs);
|
|
333
|
+
if (sha256Of(bytes) !== latest.manifestSha256) {
|
|
334
|
+
throw new Error(`the ${slug} ${latest.version} manifest does not match latest.json's manifestSha256 — nothing staged`);
|
|
335
|
+
}
|
|
336
|
+
const manifest = JSON.parse(bytes.toString('utf8'));
|
|
337
|
+
await eachLimited(mountedEntries(manifest), (entry) => readObject({ origin, entry, cacheDir, fetchImpl, timeoutMs }));
|
|
338
|
+
writeAtomic(join(cacheDir, slug, latest.version, 'manifest.json'), bytes);
|
|
339
|
+
return manifest;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Asks latest.json for a newer version; stages it, and on track latest moves the pin.
|
|
343
|
+
async function checkForUpdate({ projectDir, cacheDir = cacheRoot(), fetchImpl = globalThis.fetch, timeoutMs = 2000 }) {
|
|
344
|
+
const markerFile = join(projectDir, '.claude', 'harness.json');
|
|
345
|
+
const marker = readJsonFile(markerFile);
|
|
346
|
+
if (!marker || !marker.harness || !marker.version) return { status: 'unmarked' };
|
|
347
|
+
const { origin, track } = mountSettings(marker);
|
|
348
|
+
const slug = marker.harness;
|
|
349
|
+
let latest;
|
|
350
|
+
try {
|
|
351
|
+
latest = await readLatest({ origin, slug, fetchImpl, timeoutMs });
|
|
352
|
+
} catch (error) {
|
|
353
|
+
return { status: 'unreachable', reason: error.message };
|
|
354
|
+
}
|
|
355
|
+
if (!latest || !latest.version || compareVersions(latest.version, marker.version) <= 0) return { status: 'current' };
|
|
356
|
+
if (!existsSync(join(cacheDir, slug, latest.version, 'manifest.json'))) {
|
|
357
|
+
try {
|
|
358
|
+
await stageVersion({ origin, slug, latest, cacheDir, fetchImpl, timeoutMs: Math.max(timeoutMs, 5000) });
|
|
359
|
+
} catch (error) {
|
|
360
|
+
return { status: 'refused', reason: error.message, notice: `Harness update not staged: ${error.message}` };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const mounted = marker.version;
|
|
364
|
+
if (track === 'latest') writeAtomic(markerFile, `${JSON.stringify({ ...marker, version: latest.version }, null, 2)}\n`);
|
|
365
|
+
const tail = track === 'pinned' ? ' (track: pinned — bump `version` in .claude/harness.json to take it)' : '';
|
|
366
|
+
return {
|
|
367
|
+
status: 'staged',
|
|
368
|
+
from: mounted,
|
|
369
|
+
to: latest.version,
|
|
370
|
+
track,
|
|
371
|
+
notice: `Harness ${slug} ${latest.version} is ready (mounted ${mounted}); restart the session to run on it${tail}`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
// --- shared mount core: end ---
|
|
375
|
+
|
|
376
|
+
const HOOK_DIR = dirname(fileURLToPath(import.meta.url));
|
|
377
|
+
|
|
378
|
+
function readStdinText() {
|
|
379
|
+
try {
|
|
380
|
+
return readFileSync(0, 'utf8');
|
|
381
|
+
} catch {
|
|
382
|
+
return '';
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// The one line a mount result earns in the session context, or null when nothing happened.
|
|
387
|
+
function mountLine(result) {
|
|
388
|
+
if (result.status === 'mounted' || result.status === 'partial') {
|
|
389
|
+
const failed = result.failed.length ? `; ${result.failed.length} file(s) failed verification and kept their prior copy` : '';
|
|
390
|
+
return `Harness: mounted ${result.slug} ${result.version} from the ${result.source} — ${result.written.length} file(s) written, ${result.removed.length} removed${failed}`;
|
|
391
|
+
}
|
|
392
|
+
if (result.status === 'unavailable') {
|
|
393
|
+
return `Harness ${result.slug} ${result.version} not mounted — ${result.origin} unreachable and no cached copy; the session runs on what is on disk.`;
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Runs awareness.mjs after the mount, handing it the same payload; its stdout is returned.
|
|
399
|
+
function runAwareness(raw) {
|
|
400
|
+
const script = join(HOOK_DIR, 'awareness.mjs');
|
|
401
|
+
if (!existsSync(script)) return '';
|
|
402
|
+
const result = spawnSync(process.execPath, [script], {
|
|
403
|
+
input: raw,
|
|
404
|
+
encoding: 'utf8',
|
|
405
|
+
env: process.env,
|
|
406
|
+
timeout: 12000,
|
|
407
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
408
|
+
});
|
|
409
|
+
return result.stdout || '';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function emit(lines, notices, awareness) {
|
|
413
|
+
if (!lines.length) {
|
|
414
|
+
if (awareness) process.stdout.write(awareness);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
let parsed = null;
|
|
418
|
+
try {
|
|
419
|
+
parsed = awareness ? JSON.parse(awareness) : null;
|
|
420
|
+
} catch {
|
|
421
|
+
parsed = null;
|
|
422
|
+
}
|
|
423
|
+
const context = parsed && parsed.hookSpecificOutput && parsed.hookSpecificOutput.additionalContext;
|
|
424
|
+
const prefix = lines.join('\n');
|
|
425
|
+
const out = {
|
|
426
|
+
...(parsed || {}),
|
|
427
|
+
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: context ? `${prefix}\n\n${context}` : prefix },
|
|
428
|
+
};
|
|
429
|
+
if (notices.length) out.systemMessage = notices.join('\n');
|
|
430
|
+
process.stdout.write(JSON.stringify(out));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function main() {
|
|
434
|
+
const raw = readStdinText();
|
|
435
|
+
let payload = {};
|
|
436
|
+
try {
|
|
437
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
438
|
+
} catch {
|
|
439
|
+
payload = {};
|
|
440
|
+
}
|
|
441
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();
|
|
442
|
+
const lines = [];
|
|
443
|
+
const notices = [];
|
|
444
|
+
try {
|
|
445
|
+
const result = await mountHarness({ projectDir });
|
|
446
|
+
const line = mountLine(result);
|
|
447
|
+
if (line) lines.push(line);
|
|
448
|
+
if (result.status === 'unavailable') notices.push(line);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
lines.push(`Harness mount failed (${error.message}); the session runs on what is on disk.`);
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
const update = await checkForUpdate({ projectDir });
|
|
454
|
+
if (update.notice) {
|
|
455
|
+
lines.push(update.notice);
|
|
456
|
+
notices.push(update.notice);
|
|
457
|
+
}
|
|
458
|
+
} catch {
|
|
459
|
+
/* the update check is a convenience */
|
|
460
|
+
}
|
|
461
|
+
emit(lines, notices, runAwareness(raw));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
await main();
|
|
466
|
+
} catch {
|
|
467
|
+
/* never block a session */
|
|
468
|
+
} finally {
|
|
469
|
+
process.exitCode = 0;
|
|
470
|
+
}
|
|
@@ -7,7 +7,7 @@ description: "Use when creating or substantively editing ANY markdown doc — in
|
|
|
7
7
|
|
|
8
8
|
**This skill is a template & generator library, not an orchestrator.** It supplies the templates, the frontmatter-derivation generator, and the co-authoring workflow that _agents_ call to produce individual docs. It does **not** baseline, audit, or reconcile a repo's doc tree — that is the **`maintenance` agent (baseline mode)**, which calls this skill for each template it scaffolds. Reach here to generate _one_ doc from a template; reach for the `maintenance` agent (baseline mode) to bring a whole repo into shape.
|
|
9
9
|
|
|
10
|
-
Document contracts and architecture, not implementation details. Signatures and API shapes belong in a reference document (an API reference, an environment registry); every other genre states design and promises and leaves mechanism to the code — never paste full implementations (they rot fast).
|
|
10
|
+
Document contracts and architecture, not implementation details. Signatures and API shapes belong in a reference document (an API reference, an environment registry); every other genre states design and promises and leaves mechanism to the code — never paste full implementations (they rot fast). A diagram sits beside the prose and never replaces it: ASCII for structure at rest, mermaid for behaviour over time (`references/style-guide.md` § Diagrams). Only document state at persistence boundaries (Redux persistence, DB schemas, API shapes), not component-level or transient state. Mark deprecated fields with `// UNUSED:` comments stating why the field is dead now — never where it went or when it dies. Breaking changes belong in changelogs only, not module docs.
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
@@ -42,9 +42,9 @@ Document contracts and architecture, not implementation details. Signatures and
|
|
|
42
42
|
| "Folded in from the `NuStack-X` repo (2026-06-12)." | "Vendor-connector catalogue; one subpath per vendor." |
|
|
43
43
|
| "was X, now Y", "the old worker", "Phase 2 softened this", "v1 → v2" | The current fact, with no predecessor named |
|
|
44
44
|
|
|
45
|
-
The journey — dates, migrations, progress, supersessions — lives only in records (`docs/history/`); when reality changes, a truth document simply becomes correct. The full strip-list and the record-side rules: [`references/style-guide.md`](references/style-guide.md) § The Two Natures —
|
|
45
|
+
The journey — dates, migrations, progress, supersessions — lives only in records (`docs/history/`); when reality changes, a truth document simply becomes correct. The full strip-list and the record-side rules: [`references/style-guide.md`](references/style-guide.md) § The Two Natures — tense and history.
|
|
46
46
|
|
|
47
|
-
**A date in a truth document is a defect, and the list of exceptions is closed.** Not a softened preference: a truth document carries no dated line at all, because a date answers *when did this happen*, which is a record's question, and a reader who needs the answer is reading the wrong file. The
|
|
47
|
+
**A date in a truth document is a defect, and the list of exceptions is closed.** Not a softened preference: a truth document carries no dated line at all, because a date answers *when did this happen*, which is a record's question, and a reader who needs the answer is reading the wrong file. The three exceptions, and there are no others: the frontmatter's tooling keys (`last-reviewed:`, `last-updated:`, `rev:` — written by tooling, never by you), an Architecture Decision's **Decision Changelog** tail, and a dated path inside a link or code span, which is a *citation* of a record rather than a claim about the present. The `maintenance` agent's Health Log is a record of its own (`docs/history/health-log.md`), not a tail on a truth document.
|
|
48
48
|
|
|
49
49
|
**Cite a record by what it is, never by when it was.** `the dev reset runbook` links to the file; "the 2026-09-13 reset" states a date the reader must now keep in their head and that goes stale as soon as a second reset happens. The link carries the date in its path, where it belongs, and the prose stays true forever.
|
|
50
50
|
|
|
@@ -74,9 +74,9 @@ The two guarantees are complementary and both break on mixing: a reader holding
|
|
|
74
74
|
- **Indexes describe directories, never files.** `docs/README.md` — the top-level index of the documentation folder — is a description of its directories: one entry per folder saying what the folder holds, who reads it and how its files are named. It carries no file list and no file tree: not a per-file row, not a `├──` tree of files, not a tree at all — `docs/structure.md` is the one place the tree lives, as the authority on placement. The same holds for a `README.md` / `Readme.md` at any level of `docs/`. `ls` is the roster, and the ownership index is how a document is found for a path. A per-file row has to be edited on every add, rename and retirement, so the index's history records roster churn instead of what the index said, and a reader cannot tell when the index itself changed. The only files an index may link are the mandated scaffolds (`structure.md`, `operations/setup.md`, `history/CHANGELOG.md`, `history/backlog.md`, `memory/main.md`) and a sibling folder's own `Readme.md`. `scripts/doc-lint.mjs` blocks the rest as `index-lists-files`, and blocks a tree drawn in a folder index as `index-file-tree`. An inverse (which checklists verify a feature, which docs a folder holds) is generated, never written.
|
|
75
75
|
- **Truth documents cite records for the _why_, never the _when_.** `Provenance: AD-002` is good; "decided in AD-002 and shipped 2026-06-13" is not. Routing/index docs point only to truth documents as a topic's authority — never a plan or changelog entry as the "read first" source. (An index _of the records themselves_ — a History section listing `plans/`, `changelog/` — is sanctioned.)
|
|
76
76
|
|
|
77
|
-
**A document is intent and promises; facts are the graph's.** An owning document (feature, package, system) answers why the product is shaped this way and what it promises, and names the objects it designs in `objects:`. Every computable fact — a table's columns and vocabularies, a foreign key and its delete rule, which function writes which column, which route sits behind which guard, which code a refusal answers, which file holds what — is the code graph's to compute (`docs/architecture/systems/knowledge-layer.md`), and a hand-typed copy of it is the defect this rule removes: it reads as authoritative and is wrong the day the code moves. Mechanism stays in the code, reasons stay in the document, and the document points by object and symbol name, never by line number. `doc-lint` blocks the retired mechanism sections in an owning document (`mechanism-section`) and an owning document without `objects:`. `keywords:` is not a key: an object's name is its discovery term. **The body is
|
|
77
|
+
**A document is intent and promises; facts are the graph's.** An owning document (feature, package, system) answers why the product is shaped this way and what it promises, and names the objects it designs in `objects:`. Every computable fact — a table's columns and vocabularies, a foreign key and its delete rule, which function writes which column, which route sits behind which guard, which code a refusal answers, which file holds what — is the code graph's to compute (`docs/architecture/systems/knowledge-layer.md`), and a hand-typed copy of it is the defect this rule removes: it reads as authoritative and is wrong the day the code moves. Mechanism stays in the code, reasons stay in the document, and the document points by object and symbol name, never by line number. `doc-lint` blocks the retired mechanism sections in an owning document (`mechanism-section`) and an owning document without `objects:`. `keywords:` is not a key: an object's name is its discovery term. **The body is scanned by people** — sharp bullets under noun-phrase headings, each a bold noun-phrase label, a colon, then one or two plain sentences; code named only where a person types or sees it, or at a cross-cutting seam the graph would not lead a reader to (a middleware, a gate, a hook, a shared store); every other pointer in the closing **For the agent** footer. The owning genres read Scope · Flows (Flow for a system, Usage for a package) · Decisions · Promises · Gaps · For the agent (`references/style-guide.md` § Labels, § Owning documents — the body and the footer; `doc-lint` warns `body-identifier`, `clause-label` and `second-person`).
|
|
78
78
|
|
|
79
|
-
**Every document that explains is written from the outside in, and every sentence passes the stability test.** Open on the question and its boundary, then the model with its numbered principles, then the parts and how they relate, then each part's decisions — and stop at the genre's deepest layer: decisions for doctrine, cross-package behaviour for a system, the public contract for a package, the full user-facing behaviour for a feature. A sentence that would have to change when a function is renamed, a file moves, or a route, column or error code changes is the code's, not the document's. The code is the authority on *what*; the document is the authority on *why*, and a disagreement on *why* is a defect to raise. The layers, the test and the per-genre depth table are [`references/style-guide.md`](references/style-guide.md) § Shape — the model first, the detail after.
|
|
79
|
+
**Every document that explains is written from the outside in, and every sentence passes the stability test.** Open on the question and its boundary, then the model with its numbered principles, then the parts and how they relate, then each part's decisions — and stop at the genre's deepest layer: decisions for doctrine, cross-package behaviour for a system, the public contract for a package, the full user-facing behaviour for a feature. A sentence that would have to change when a function is renamed, a file moves, or a route, column or error code changes is the code's, not the document's. The code is the authority on *what*; the document is the authority on *why*, and a disagreement on *why* is a defect to raise. **Every heading and every bold bullet label is a noun phrase** — it names; it has no verb, so it carries no tense, person or voice ("Decisions", never "What was decided"; "Organization user:", never "You already belong to an organization:"), and the sentence after it is third person ("user lands…", never "you land…"). The layers, the test, the label rule and the per-genre depth table are [`references/style-guide.md`](references/style-guide.md) § Shape — the model first, the detail after, and § Labels.
|
|
80
80
|
|
|
81
81
|
**No empty stubs.** Never leave a template section as an empty placeholder — omit any section with nothing real to say (each template declares its required core; everything else is optional). Write an explicit "None" only where the absence itself is information (e.g., a plan's Cross-Module Contracts Affected, an LLD's Ownership Conflicts). An empty table stub reads as "not yet researched"; an omitted section reads as "doesn't apply" — keep those meanings distinct.
|
|
82
82
|
|
|
@@ -186,7 +186,7 @@ update, and the `maintenance` agent's baseline pass, route there.
|
|
|
186
186
|
| File | Use For |
|
|
187
187
|
| --- | --- |
|
|
188
188
|
| `templates/` | One file per template, indexed by [`Readme.md`](templates/Readme.md) — feature (LLD), changelog, plan, AD, steering docs, API reference, environment registry, operations procedure, migration runbook, protected files, READMEs, and the baseline scaffolds |
|
|
189
|
-
| `references/style-guide.md` |
|
|
189
|
+
| `references/style-guide.md` | The writing-style authority: the eight principles, depth per genre, the outside-in shape and stability test, noun-phrase labels and third-person voice, the owning document's body and footer, the two-natures tense rules, diagrams, headings, reference-document detail |
|
|
190
190
|
| `references/instruction-style.md` | The style for instruction files (`CLAUDE.md`, rules, agents, skill prose) — the every-line test, armor vs. narration, voice, negative space |
|
|
191
191
|
| `references/frontmatter.md` | Deriving a feature doc's `objects:` / `owns:` / `module:` / `status:` from code |
|
|
192
192
|
| `references/untemplated.md` | How to write a document no template covers (nature, placement, shape) + the co-authoring workflow |
|
|
@@ -146,37 +146,41 @@ Replaced change-tracking merge with version-based conflict resolution.
|
|
|
146
146
|
|
|
147
147
|
---
|
|
148
148
|
|
|
149
|
-
## Example 4:
|
|
149
|
+
## Example 4: Flow
|
|
150
150
|
|
|
151
|
-
### ❌ Bad:
|
|
151
|
+
### ❌ Bad: Mechanism narrated in a paragraph
|
|
152
152
|
|
|
153
153
|
```markdown
|
|
154
|
+
## Data Flow
|
|
155
|
+
|
|
154
156
|
When the page loads, the useAppInit hook calls the syncData function. This function first fetches data from the server using the API. Then it gets the local data from Redux. After that, it compares versions...
|
|
155
157
|
```
|
|
156
158
|
|
|
157
|
-
|
|
159
|
+
**Problems:** names a hook, a function and a store that a rename would invalidate (the stability test); runs parallel cases together in one paragraph; gives a scanning reader no label to land on; "Data Flow" is a retired mechanism section.
|
|
158
160
|
|
|
159
|
-
|
|
160
|
-
## Data Flow
|
|
161
|
+
### ✅ Good: Labelled bullets, a role-level diagram beside them
|
|
161
162
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
163
|
+
````markdown
|
|
164
|
+
## Flows
|
|
165
|
+
|
|
166
|
+
### Page load
|
|
167
|
+
|
|
168
|
+
- **Fresh data:** the page fetches the server's copy and the local copy on every load; nothing is cached between visits.
|
|
169
|
+
- **Matching versions:** local changes win and are kept.
|
|
170
|
+
- **Mismatched versions:** the server's copy wins and local changes are dropped.
|
|
171
|
+
|
|
172
|
+
```mermaid
|
|
173
|
+
sequenceDiagram
|
|
174
|
+
participant Page
|
|
175
|
+
participant Server
|
|
176
|
+
participant Store
|
|
177
|
+
Page->>Server: fetch the server's copy
|
|
178
|
+
Page->>Store: read the local copy
|
|
179
|
+
Page->>Store: write the merged copy
|
|
179
180
|
```
|
|
181
|
+
````
|
|
182
|
+
|
|
183
|
+
**Why it works:** every fact is in the bullets, so a reader who greps finds it without the diagram; the labels are noun phrases a scanning reader lands on; the diagram names roles, never a function or a file, so no rename touches it.
|
|
180
184
|
|
|
181
185
|
---
|
|
182
186
|
|
|
@@ -242,7 +246,7 @@ The feature doc states only what is:
|
|
|
242
246
|
```markdown
|
|
243
247
|
## Export Pipeline
|
|
244
248
|
|
|
245
|
-
|
|
249
|
+
The exporter renders every export format from one job queue. Output is
|
|
246
250
|
buffered per job (streaming is out of scope — see `docs/history/backlog.md`).
|
|
247
251
|
```
|
|
248
252
|
|
|
@@ -272,8 +276,8 @@ Extracted export logic from the `reports` worker into `src/export/`.
|
|
|
272
276
|
| ---------- | -------------------- | --------------------------- |
|
|
273
277
|
| Code | Full implementations | Signatures + contracts |
|
|
274
278
|
| State | Every occurrence | Persistence points only |
|
|
275
|
-
| Flows |
|
|
279
|
+
| Flows | Paragraphs naming functions | Labelled bullets in plain words, a role-level diagram beside them |
|
|
276
280
|
| Changelogs | "Updated X" | Function names + migrations |
|
|
277
281
|
| Decisions | New doc per revision | One AD per subject, updated in place |
|
|
278
282
|
| History | Narrated in the doc | Present-tense doc + dated record |
|
|
279
|
-
| Length |
|
|
283
|
+
| Length | Padded with mechanism | The genre's own expectation; length is a signal, never a target |
|