@nurix/nustack 0.23.12 → 0.23.14
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/dist/commands/agent.js +1 -53
- package/dist/commands/check.js +73 -0
- package/dist/commands/deploy.js +2 -1
- package/dist/commands/doctor.js +2 -1
- package/dist/commands/harness.js +127 -48
- package/dist/commands/init.js +103 -3
- package/dist/commands/login.js +9 -2
- package/dist/commands/mcp.js +7 -0
- package/dist/commands/project-sync.js +2 -1
- package/dist/commands/read.js +19 -0
- package/dist/commands/records.js +10 -0
- package/dist/commands/review.js +3 -2
- package/dist/commands/sessions.js +4 -1
- package/dist/commands/sync.js +2 -1
- package/dist/index.js +4 -0
- package/dist/lib/agent/claudeHooks.js +4 -1
- package/dist/lib/agent/desktopDoor.js +55 -0
- package/dist/lib/auth_grant.js +15 -3
- package/dist/lib/auth_recovery.js +10 -3
- package/dist/lib/etna/manager.js +24 -6
- package/dist/lib/etna/profile.js +6 -0
- package/dist/lib/etna/reconcile.js +6 -0
- package/dist/lib/gate/bulkReadRelay.js +354 -0
- package/dist/lib/gate/hook.js +30 -3
- package/dist/lib/gate/outline.js +12 -0
- package/dist/lib/gate/settings.js +24 -1
- package/dist/lib/gate/tierOne.js +22 -0
- package/dist/lib/harness/codes.js +24 -0
- package/dist/lib/harness/hookCheck.js +325 -0
- package/dist/lib/harness/hookEntry.js +27 -0
- package/dist/lib/harness/legacyMount.js +231 -0
- package/dist/lib/harness/mount.js +199 -131
- package/dist/lib/harness/storeWaivers.js +43 -0
- package/dist/lib/harness/vendor.js +29 -0
- package/dist/lib/harness/versionsClient.js +54 -0
- package/dist/lib/local_store/database.js +18 -0
- package/dist/lib/local_store/routing.js +26 -0
- package/dist/lib/nustack_client.js +35 -2
- package/dist/lib/onboarding/attach_existing_git.js +14 -2
- package/dist/lib/onboarding/binding.js +46 -2
- package/dist/lib/onboarding/commit_trailer_hook.js +66 -0
- package/dist/lib/onboarding/manifest_writer.js +19 -3
- package/dist/lib/onboarding/session_hook.js +76 -0
- package/dist/lib/origin_guard.js +43 -0
- package/dist/lib/project_contract.js +104 -0
- package/dist/lib/records/door.js +26 -19
- package/dist/lib/records/drain.js +10 -4
- package/dist/lib/records/refusalLog.js +30 -1
- package/dist/lib/session-engine/supervisor.js +6 -18
- package/dist/lib/sessions/client.js +0 -12
- package/dist/program.js +27 -4
- package/dist/vendor/harness/assemble/runtimes.js +32 -0
- package/dist/vendor/harness/assemble/settingsMerge.js +136 -0
- package/dist/vendor/harness/digest/index.js +38 -0
- package/dist/vendor/harness/lib/findings.js +32 -0
- package/dist/vendor/harness/lib/frontmatter.js +79 -0
- package/dist/vendor/harness/lib/paths.js +31 -0
- package/dist/vendor/harness/mount/cache.js +65 -0
- package/dist/vendor/harness/mount/exclude.js +77 -0
- package/dist/vendor/harness/mount/files.js +38 -0
- package/dist/vendor/harness/mount/index.js +352 -0
- package/dist/vendor/harness/rules/changeRules.js +162 -0
- package/dist/vendor/harness/rules/context.js +38 -0
- package/dist/vendor/harness/rules/docsRoot.js +34 -0
- package/dist/vendor/harness/rules/index.js +109 -0
- package/dist/vendor/harness/rules/node.js +144 -0
- package/dist/vendor/harness/rules/outputGate.js +28 -0
- package/dist/vendor/harness/rules/ownership.js +178 -0
- package/dist/vendor/harness/rules/packageTable.js +113 -0
- package/dist/vendor/harness/rules/plans.js +96 -0
- package/dist/vendor/harness/rules/sessionWalls.js +31 -0
- package/package.json +5 -5
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// packages/harness/src/mount/index.ts - Mounts one runtime's half of a harness version into a repository: the digest
|
|
2
|
+
// verified over the whole manifest, every object verified (fetched or cached) before the first write, writes deepest
|
|
3
|
+
// path first, contract and seed files kept when a person owns them, merge targets folded, the unmodified files a
|
|
4
|
+
// version drops pruned, the mounted paths kept out of git, and the mount record written by its one writer.
|
|
5
|
+
// Ported from etna packages/cli/lib/install/mount.mjs and packages/cli/src/lib/harness/mount.ts.
|
|
6
|
+
// docs/architecture/systems/harness-records.md speaks for it (Harness mount).
|
|
7
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { DOCS_ROOT_TOKEN, INSTRUCTION_FILE_OF, isRuntime, MERGE_TARGET_OF, splitRuntimePath } from '../assemble/runtimes.js';
|
|
10
|
+
import { mergeClaudeSettings, mergeCodexHooks, mergeMcpServers } from '../assemble/settingsMerge.js';
|
|
11
|
+
import { contentDigest } from '../digest/index.js';
|
|
12
|
+
import { depthOf, isSafeRelativePath } from '../lib/paths.js';
|
|
13
|
+
import { resolveDocsRoot } from '../rules/docsRoot.js';
|
|
14
|
+
import { defaultHarnessCacheDir, evictCacheFolder, newestCachedDigest, readCachedManifest, readCachedObject, writeCachedManifest, writeCachedObject, } from './cache.js';
|
|
15
|
+
import { trackedMergeTargets, writeExcludeBlock } from './exclude.js';
|
|
16
|
+
import { readBytes, removeEmptyParents, sha256Of, writeAtomic } from './files.js';
|
|
17
|
+
export { FINDING_CODES } from '../lib/findings.js';
|
|
18
|
+
export { defaultHarnessCacheDir, newestCachedDigest, HARNESS_CACHE_DIR_ENV } from './cache.js';
|
|
19
|
+
export { MOUNT_EXCLUDE_START, MOUNT_EXCLUDE_END, resolveGitDirs, excludeBlockLines } from './exclude.js';
|
|
20
|
+
export const MOUNT_RECORD_PATH = '.claude/state/mount.json';
|
|
21
|
+
export const NO_ROUTE_LINE = 'harness not mounted: no route to NuStack and no cached version';
|
|
22
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
23
|
+
const FETCH_CONCURRENCY = 8;
|
|
24
|
+
const MERGE_OF = { 'claude-settings': mergeClaudeSettings, 'codex-hooks': mergeCodexHooks, 'mcp-servers': mergeMcpServers };
|
|
25
|
+
/** The mount record, or null when none parses; an older record parses as one with no `runtimes`. */
|
|
26
|
+
export function readMountRecord(root) {
|
|
27
|
+
const bytes = readBytes(join(root, MOUNT_RECORD_PATH));
|
|
28
|
+
if (!bytes)
|
|
29
|
+
return null;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(bytes.toString('utf8'));
|
|
32
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** A repository path the mount may write: safe, outside `.git` and outside the mount's own state folder. */
|
|
39
|
+
function isWritablePath(path) {
|
|
40
|
+
return isSafeRelativePath(path) && !path.split('/').includes('.git') && !path.startsWith('.claude/state/');
|
|
41
|
+
}
|
|
42
|
+
/** The first reason the manifest must be refused, before anything is fetched or written. */
|
|
43
|
+
function manifestProblem(manifest, expectedDigest) {
|
|
44
|
+
if (!manifest || typeof manifest !== 'object' || manifest.schemaVersion !== 1)
|
|
45
|
+
return { code: 'mount.schema_unknown', message: `schemaVersion ${String(manifest?.schemaVersion)} is not 1` };
|
|
46
|
+
if (!Array.isArray(manifest.files))
|
|
47
|
+
return { code: 'mount.schema_unknown', message: 'the manifest lists no files' };
|
|
48
|
+
for (const entry of manifest.files) {
|
|
49
|
+
if (typeof entry?.sha256 !== 'string' || !SHA256.test(entry.sha256))
|
|
50
|
+
return { code: 'mount.bad_sha256', message: `${String(entry?.path)}: sha256 is not 64 lowercase hex`, path: String(entry?.path) };
|
|
51
|
+
const split = typeof entry.path === 'string' && isSafeRelativePath(entry.path) ? splitRuntimePath(entry.path) : null;
|
|
52
|
+
if (!split)
|
|
53
|
+
return { code: 'mount.unsafe_path', message: `${String(entry.path)}: not a safe <runtime>/<path>`, path: String(entry.path) };
|
|
54
|
+
const placed = entry.policy === 'seed' ? (split.path.startsWith(`${DOCS_ROOT_TOKEN}/`) ? split.path.slice(DOCS_ROOT_TOKEN.length + 1) : '') : split.path;
|
|
55
|
+
if (!isWritablePath(placed) || (entry.policy !== 'seed' && placed.startsWith('@')))
|
|
56
|
+
return { code: 'mount.unsafe_path', message: `${entry.path}: unsafe repository path`, path: entry.path };
|
|
57
|
+
if (entry.policy === 'contract' && split.path !== INSTRUCTION_FILE_OF[split.runtime])
|
|
58
|
+
return { code: 'mount.contract_outside', message: `${entry.path}: a contract entry outside ${split.runtime}'s instruction file`, path: entry.path };
|
|
59
|
+
if (entry.policy !== 'mount' && entry.policy !== 'contract' && entry.policy !== 'seed')
|
|
60
|
+
return { code: 'mount.schema_unknown', message: `${entry.path}: unknown policy ${String(entry.policy)}`, path: entry.path };
|
|
61
|
+
if (entry.merge !== undefined && (entry.policy !== 'mount' || MERGE_TARGET_OF[entry.merge] !== split.path))
|
|
62
|
+
return { code: 'mount.unsafe_path', message: `${entry.path}: merge ${String(entry.merge)} at the wrong path`, path: entry.path };
|
|
63
|
+
}
|
|
64
|
+
if (contentDigest(manifest.files) !== manifest.digest)
|
|
65
|
+
return { code: 'mount.digest_mismatch', message: 'the manifest digest does not match its files' };
|
|
66
|
+
if (expectedDigest && manifest.digest !== expectedDigest)
|
|
67
|
+
return { code: 'mount.expected_digest_mismatch', message: `the manifest digest ${manifest.digest} is not the expected ${expectedDigest}` };
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/** Runs task over items with at most `limit` in flight. */
|
|
71
|
+
async function eachLimited(items, limit, task) {
|
|
72
|
+
let next = 0;
|
|
73
|
+
const worker = async () => {
|
|
74
|
+
while (next < items.length) {
|
|
75
|
+
const item = items[next];
|
|
76
|
+
next += 1;
|
|
77
|
+
await task(item);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
81
|
+
}
|
|
82
|
+
export async function mountVersion(options) {
|
|
83
|
+
const runtime = (options.runtime ?? 'claude-code');
|
|
84
|
+
const root = options.root;
|
|
85
|
+
const cacheDir = options.cacheDir ?? defaultHarnessCacheDir();
|
|
86
|
+
const writeContract = options.writeContract ?? 'when-unmodified';
|
|
87
|
+
const result = { status: 'unavailable', digest: null, semver: null, runtime, source: null, written: [], kept: [], removed: [], contractDrift: [], findings: [], line: '' };
|
|
88
|
+
const finish = (status, line, finding) => {
|
|
89
|
+
if (finding)
|
|
90
|
+
result.findings.push({ code: finding.code, message: finding.message, ...(finding.path ? { path: finding.path } : {}) });
|
|
91
|
+
return { ...result, status, line };
|
|
92
|
+
};
|
|
93
|
+
const refuse = (finding) => finish('refused', `harness not mounted: ${finding.message}`, finding);
|
|
94
|
+
if (!isRuntime(runtime))
|
|
95
|
+
return refuse({ code: 'mount.runtime_unknown', message: `unknown runtime ${String(runtime)}` });
|
|
96
|
+
// The manifest: the caller's, verified; else the cache entry the expected digest or the project's newest names.
|
|
97
|
+
let manifest;
|
|
98
|
+
let source;
|
|
99
|
+
if (options.manifest) {
|
|
100
|
+
const problem = manifestProblem(options.manifest, options.expectedDigest);
|
|
101
|
+
if (problem)
|
|
102
|
+
return refuse(problem);
|
|
103
|
+
manifest = options.manifest;
|
|
104
|
+
source = 'network';
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const projectId = options.projectId;
|
|
108
|
+
let digest = options.expectedDigest;
|
|
109
|
+
if (!digest) {
|
|
110
|
+
if (!projectId)
|
|
111
|
+
return refuse({ code: 'mount.project_unknown', message: 'no manifest, no expected digest and no projectId to find a cached version by' });
|
|
112
|
+
digest = newestCachedDigest(cacheDir, projectId)?.digest ?? null;
|
|
113
|
+
}
|
|
114
|
+
const cached = digest ? readCachedManifest(cacheDir, digest) : null;
|
|
115
|
+
if (!digest || !cached)
|
|
116
|
+
return finish('unavailable', NO_ROUTE_LINE, { code: 'mount.no_route', message: NO_ROUTE_LINE });
|
|
117
|
+
const problem = manifestProblem(cached, digest);
|
|
118
|
+
if (problem || cached.digest !== digest) {
|
|
119
|
+
evictCacheFolder(cacheDir, digest);
|
|
120
|
+
return refuse({ code: 'mount.cache_corrupt', message: `the cached version ${digest} failed verification (${problem?.message ?? 'its digest is not its folder'}) and was evicted` });
|
|
121
|
+
}
|
|
122
|
+
manifest = cached;
|
|
123
|
+
source = 'cache';
|
|
124
|
+
}
|
|
125
|
+
result.digest = manifest.digest;
|
|
126
|
+
result.semver = manifest.semver;
|
|
127
|
+
result.source = source;
|
|
128
|
+
const planned = manifest.files.flatMap((entry) => {
|
|
129
|
+
const split = splitRuntimePath(entry.path);
|
|
130
|
+
return split && split.runtime === runtime ? [{ entry, path: split.path }] : [];
|
|
131
|
+
});
|
|
132
|
+
const record = readMountRecord(root) ?? {};
|
|
133
|
+
const prior = record.runtimes?.[runtime];
|
|
134
|
+
// Current: the record names this digest for this runtime and every recorded file still matches.
|
|
135
|
+
if (prior && prior.digest === manifest.digest && Array.isArray(prior.files) && prior.files.every((file) => sha256Of(readBytes(join(root, file.path)) ?? '\0missing') === file.sha256)) {
|
|
136
|
+
result.kept = prior.files.map((file) => file.path);
|
|
137
|
+
return finish('current', `harness current: ${manifest.semver} (${manifest.digest.slice(0, 12)}) for ${runtime}`);
|
|
138
|
+
}
|
|
139
|
+
const docsRoot = resolveDocsRoot({ read: (path) => readBytes(join(root, path))?.toString('utf8') ?? null });
|
|
140
|
+
const placedPath = (item) => (item.entry.policy === 'seed' ? `${docsRoot}/${item.path.slice(DOCS_ROOT_TOKEN.length + 1)}` : item.path);
|
|
141
|
+
const onDisk = new Map();
|
|
142
|
+
const disk = (path) => {
|
|
143
|
+
if (!onDisk.has(path))
|
|
144
|
+
onDisk.set(path, readBytes(join(root, path)));
|
|
145
|
+
return onDisk.get(path);
|
|
146
|
+
};
|
|
147
|
+
const priorSha = new Map((prior?.files ?? []).map((file) => [file.path, file.sha256]));
|
|
148
|
+
// Decide what each entry needs before fetching anything: only the bytes a write uses are fetched.
|
|
149
|
+
const needed = [];
|
|
150
|
+
const unchanged = [];
|
|
151
|
+
for (const item of planned) {
|
|
152
|
+
const target = placedPath(item);
|
|
153
|
+
const held = disk(target);
|
|
154
|
+
const heldSha = held ? sha256Of(held) : null;
|
|
155
|
+
if (item.entry.merge)
|
|
156
|
+
needed.push(item);
|
|
157
|
+
else if (item.entry.policy === 'mount') {
|
|
158
|
+
if (heldSha === item.entry.sha256)
|
|
159
|
+
unchanged.push(target);
|
|
160
|
+
else
|
|
161
|
+
needed.push(item);
|
|
162
|
+
}
|
|
163
|
+
else if (item.entry.policy === 'seed') {
|
|
164
|
+
if (held)
|
|
165
|
+
result.kept.push(target);
|
|
166
|
+
else if (writeContract !== 'never')
|
|
167
|
+
needed.push(item);
|
|
168
|
+
}
|
|
169
|
+
else if (heldSha === item.entry.sha256)
|
|
170
|
+
unchanged.push(target);
|
|
171
|
+
else if (writeContract === 'never') {
|
|
172
|
+
if (held)
|
|
173
|
+
result.kept.push(target);
|
|
174
|
+
}
|
|
175
|
+
else if (!held || writeContract === 'always' || priorSha.get(target) === heldSha)
|
|
176
|
+
needed.push(item);
|
|
177
|
+
else {
|
|
178
|
+
result.kept.push(target);
|
|
179
|
+
result.contractDrift.push(target);
|
|
180
|
+
result.findings.push({ code: 'mount.contract_edited', message: `${target} differs from what the last mount wrote; left as edited`, path: target });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Every needed object, from the cache (verified) or fetched (verified), before the first write.
|
|
184
|
+
const bytesOf = new Map();
|
|
185
|
+
const fetched = [];
|
|
186
|
+
for (const item of needed) {
|
|
187
|
+
const cached = readCachedObject(cacheDir, manifest.digest, item.entry);
|
|
188
|
+
if (cached && sha256Of(cached) === item.entry.sha256)
|
|
189
|
+
bytesOf.set(item.entry.path, cached);
|
|
190
|
+
else if (source === 'cache') {
|
|
191
|
+
if (cached) {
|
|
192
|
+
evictCacheFolder(cacheDir, manifest.digest);
|
|
193
|
+
return refuse({ code: 'mount.cache_corrupt', message: `the cached ${item.entry.path} does not match its entry; the cached version was evicted`, path: item.entry.path });
|
|
194
|
+
}
|
|
195
|
+
return finish('unavailable', `harness not mounted: no route to NuStack and the cached version lacks ${item.entry.path}`, { code: 'mount.no_route', message: `the cached version lacks ${item.entry.path}`, path: item.entry.path });
|
|
196
|
+
}
|
|
197
|
+
else
|
|
198
|
+
fetched.push(item);
|
|
199
|
+
}
|
|
200
|
+
let failure = null;
|
|
201
|
+
await eachLimited(fetched, FETCH_CONCURRENCY, async (item) => {
|
|
202
|
+
if (failure)
|
|
203
|
+
return;
|
|
204
|
+
try {
|
|
205
|
+
const bytes = await options.fetchObject(item.entry);
|
|
206
|
+
if (sha256Of(bytes) !== item.entry.sha256)
|
|
207
|
+
failure ??= { code: 'mount.object_mismatch', message: `${item.entry.path}: the object does not match its sha256`, path: item.entry.path };
|
|
208
|
+
else
|
|
209
|
+
bytesOf.set(item.entry.path, bytes);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
failure ??= { code: 'mount.fetch_failed', message: `fetching ${item.entry.path} failed: ${error instanceof Error ? error.message : String(error)}`, path: item.entry.path };
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
if (failure) {
|
|
216
|
+
const found = failure;
|
|
217
|
+
return found.code === 'mount.fetch_failed' ? finish('unavailable', `harness not mounted: ${found.message}`, found) : refuse(found);
|
|
218
|
+
}
|
|
219
|
+
// The cache: fetched objects, then the manifest last as the completeness mark.
|
|
220
|
+
for (const item of fetched)
|
|
221
|
+
writeCachedObject(cacheDir, manifest.digest, item.entry, bytesOf.get(item.entry.path));
|
|
222
|
+
if (source === 'network')
|
|
223
|
+
writeCachedManifest(cacheDir, manifest, runtime);
|
|
224
|
+
// Merge targets: tracked ones are left alone; an unparsable one is left alone; the rest fold the fragment in.
|
|
225
|
+
const merges = needed.filter((item) => item.entry.merge);
|
|
226
|
+
const mergeTargets = [...new Set([...merges.map((item) => item.path), ...Object.keys(prior?.managed ?? {})])];
|
|
227
|
+
const tracked = trackedMergeTargets(root, mergeTargets);
|
|
228
|
+
const managed = {};
|
|
229
|
+
const createdMergeTargets = new Set((prior?.createdMergeTargets ?? []).filter((path) => existsSync(join(root, path))));
|
|
230
|
+
const mergeWrites = [];
|
|
231
|
+
for (const target of mergeTargets) {
|
|
232
|
+
const item = merges.find((candidate) => candidate.path === target);
|
|
233
|
+
const kind = (item?.entry.merge ?? Object.entries(MERGE_TARGET_OF).find(([, path]) => path === target)?.[0]);
|
|
234
|
+
if (!kind)
|
|
235
|
+
continue;
|
|
236
|
+
if (tracked.has(target)) {
|
|
237
|
+
result.findings.push({ code: 'mount.merge_target_tracked', message: `${target} is tracked by git; left untouched`, path: target });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const held = disk(target);
|
|
241
|
+
let existing = null;
|
|
242
|
+
if (held) {
|
|
243
|
+
try {
|
|
244
|
+
const parsed = JSON.parse(held.toString('utf8'));
|
|
245
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
246
|
+
throw new Error('not an object');
|
|
247
|
+
existing = parsed;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
result.findings.push({ code: 'mount.merge_target_unparsable', message: `${target} does not parse as a JSON object; left untouched`, path: target });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
let fragment = {};
|
|
255
|
+
if (item) {
|
|
256
|
+
try {
|
|
257
|
+
fragment = JSON.parse(Buffer.from(bytesOf.get(item.entry.path)).toString('utf8'));
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return refuse({ code: 'mount.object_mismatch', message: `${item.entry.path}: the merge fragment is not JSON`, path: item.entry.path });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const merged = MERGE_OF[kind](existing, fragment, prior?.managed?.[target]);
|
|
264
|
+
if (Object.keys(merged.managed).length > 0)
|
|
265
|
+
managed[target] = merged.managed;
|
|
266
|
+
const content = `${JSON.stringify(merged.merged, null, 2)}\n`;
|
|
267
|
+
if (held && held.toString('utf8') === content)
|
|
268
|
+
continue;
|
|
269
|
+
if (!held) {
|
|
270
|
+
if (!item)
|
|
271
|
+
continue;
|
|
272
|
+
createdMergeTargets.add(target);
|
|
273
|
+
}
|
|
274
|
+
mergeWrites.push({ target, content });
|
|
275
|
+
}
|
|
276
|
+
// All writes, deepest path first, so a module lands before the file importing it.
|
|
277
|
+
const writes = needed.filter((item) => !item.entry.merge).map((item) => ({ target: placedPath(item), bytes: bytesOf.get(item.entry.path) }));
|
|
278
|
+
writes.sort((a, b) => depthOf(b.target) - depthOf(a.target) || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0));
|
|
279
|
+
for (const write of writes) {
|
|
280
|
+
writeAtomic(join(root, write.target), write.bytes);
|
|
281
|
+
result.written.push(write.target);
|
|
282
|
+
}
|
|
283
|
+
for (const write of mergeWrites) {
|
|
284
|
+
writeAtomic(join(root, write.target), write.content);
|
|
285
|
+
result.written.push(write.target);
|
|
286
|
+
}
|
|
287
|
+
result.kept.push(...unchanged);
|
|
288
|
+
// What this runtime now holds: mount and contract files whose bytes equal their entry.
|
|
289
|
+
const files = [];
|
|
290
|
+
for (const item of planned) {
|
|
291
|
+
if (item.entry.merge || item.entry.policy === 'seed')
|
|
292
|
+
continue;
|
|
293
|
+
const target = placedPath(item);
|
|
294
|
+
const held = result.written.includes(target) ? item.entry.sha256 : sha256Of(disk(target) ?? '\0missing');
|
|
295
|
+
if (held === item.entry.sha256)
|
|
296
|
+
files.push({ path: target, sha256: item.entry.sha256, policy: item.entry.policy });
|
|
297
|
+
}
|
|
298
|
+
// Prune: a file the previous mount of this runtime recorded and this version omits, still unmodified, and no
|
|
299
|
+
// other runtime's record listing it.
|
|
300
|
+
const now = new Set(files.map((file) => file.path));
|
|
301
|
+
const otherRuntimes = Object.entries(record.runtimes ?? {}).filter(([name]) => name !== runtime);
|
|
302
|
+
const listedElsewhere = (path) => otherRuntimes.some(([, other]) => (other?.files ?? []).some((file) => file.path === path));
|
|
303
|
+
for (const old of prior?.files ?? []) {
|
|
304
|
+
if (old.policy !== 'mount' || now.has(old.path) || planned.some((item) => placedPath(item) === old.path))
|
|
305
|
+
continue;
|
|
306
|
+
const held = readBytes(join(root, old.path));
|
|
307
|
+
if (!held)
|
|
308
|
+
continue;
|
|
309
|
+
if (sha256Of(held) !== old.sha256) {
|
|
310
|
+
result.kept.push(old.path);
|
|
311
|
+
result.findings.push({ code: 'mount.kept_edited', message: `${old.path} was dropped by this version but edited since the last mount; left in place`, path: old.path });
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (listedElsewhere(old.path))
|
|
315
|
+
continue;
|
|
316
|
+
rmSync(join(root, old.path), { force: true });
|
|
317
|
+
removeEmptyParents(root, old.path);
|
|
318
|
+
result.removed.push(old.path);
|
|
319
|
+
}
|
|
320
|
+
// Out of git: every runtime's mounted files and the merge targets a mount created.
|
|
321
|
+
const runtimes = { ...(record.runtimes ?? {}) };
|
|
322
|
+
const mountedAt = new Date().toISOString();
|
|
323
|
+
runtimes[runtime] = {
|
|
324
|
+
digest: manifest.digest,
|
|
325
|
+
treeDigest: contentDigest(planned.filter((item) => !item.entry.merge && item.entry.policy !== 'seed').map((item) => ({ path: placedPath(item), sha256: item.entry.sha256 }))),
|
|
326
|
+
semver: manifest.semver,
|
|
327
|
+
source,
|
|
328
|
+
mountedAt,
|
|
329
|
+
files,
|
|
330
|
+
managed,
|
|
331
|
+
createdMergeTargets: [...createdMergeTargets].sort(),
|
|
332
|
+
};
|
|
333
|
+
const excluded = Object.values(runtimes).flatMap((entry) => [...(entry?.files ?? []).filter((file) => file.policy === 'mount').map((file) => file.path), ...(entry?.createdMergeTargets ?? [])]);
|
|
334
|
+
writeExcludeBlock(root, excluded);
|
|
335
|
+
// The record, by its one writer: unknown top-level keys kept, the caller's extras beside the owned keys.
|
|
336
|
+
const stateDir = join(root, '.claude', 'state');
|
|
337
|
+
mkdirSync(stateDir, { recursive: true });
|
|
338
|
+
if (!existsSync(join(stateDir, '.gitignore')))
|
|
339
|
+
writeFileSync(join(stateDir, '.gitignore'), '*\n');
|
|
340
|
+
const body = {
|
|
341
|
+
...record,
|
|
342
|
+
...(options.recordExtras ?? {}),
|
|
343
|
+
version: manifest.semver,
|
|
344
|
+
digest: manifest.digest,
|
|
345
|
+
mountedAt,
|
|
346
|
+
projectId: options.projectId ?? manifest.projectId,
|
|
347
|
+
source,
|
|
348
|
+
runtimes,
|
|
349
|
+
};
|
|
350
|
+
writeAtomic(join(root, MOUNT_RECORD_PATH), `${JSON.stringify(body, null, 2)}\n`);
|
|
351
|
+
return finish('mounted', `harness mounted: ${manifest.semver} (${manifest.digest.slice(0, 12)}) for ${runtime} from ${source}`);
|
|
352
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// packages/harness/src/rules/changeRules.ts - The change rules over a tree and a change set: plan-gate, ledger,
|
|
2
|
+
// frozen-record, changelog, changelog-entry and protected-file. Ported from etna harness/code/hooks/compliance.mjs,
|
|
3
|
+
// check-completion-integrity.mjs, check-changelog-presence.mjs, lib/frozen.mjs and lib/exempt.mjs.
|
|
4
|
+
// docs/architecture/systems/harness-records.md speaks for it (Status check and enforcement).
|
|
5
|
+
import { field, frontmatter } from '../lib/frontmatter.js';
|
|
6
|
+
import { basenamePosix, joinPosix } from '../lib/paths.js';
|
|
7
|
+
import { backtickTokens, markdownIn, SOURCE_EXT, waiverCovers } from './context.js';
|
|
8
|
+
import { inPlanScope, openGatedPlans, plansFolder, undischargedDocRows, undischargedRows } from './plans.js';
|
|
9
|
+
const changedPaths = (change) => (change.status === 'R' && change.from ? [change.path, change.from] : [change.path]);
|
|
10
|
+
export function planGateRule(ctx, root) {
|
|
11
|
+
const findings = [];
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
const add = (finding) => {
|
|
14
|
+
const key = `${finding.id}|${finding.path}`;
|
|
15
|
+
if (!seen.has(key))
|
|
16
|
+
findings.push(finding), seen.add(key);
|
|
17
|
+
};
|
|
18
|
+
const sources = (ctx.changes ?? []).map((change) => change.path).filter((rel) => !(rel.startsWith(`${root}/`) || rel.startsWith('.claude/') || rel.endsWith('.md')) && SOURCE_EXT.test(rel));
|
|
19
|
+
if (sources.length === 0)
|
|
20
|
+
return findings;
|
|
21
|
+
const plans = openGatedPlans(ctx.head, root);
|
|
22
|
+
for (const rel of sources) {
|
|
23
|
+
for (const plan of plans) {
|
|
24
|
+
if (plan.scope.length && !inPlanScope(rel, plan.scope))
|
|
25
|
+
continue;
|
|
26
|
+
const boundary = /Boundary:\s*(\w+)/i.exec(plan.gates ?? '');
|
|
27
|
+
if (boundary && !/^passed$/i.test(boundary[1])) {
|
|
28
|
+
add({ id: /^blocked$/i.test(boundary[1]) ? 'plan-gate.boundary_blocked' : 'plan-gate.boundary_pending', path: plan.path, message: `${rel} changes inside ${plan.file}, whose Gates line reads 'Boundary: ${boundary[1]}'` });
|
|
29
|
+
}
|
|
30
|
+
if (!plan.hasLedger)
|
|
31
|
+
add({ id: 'plan-gate.no_test_ledger', path: plan.path, message: `${rel} changes inside ${plan.file}, which has no ## Test Obligations section` });
|
|
32
|
+
if (!plan.hasDocLedger)
|
|
33
|
+
add({ id: 'plan-gate.no_doc_ledger', path: plan.path, message: `${rel} changes inside ${plan.file}, which has no ## Doc Obligations section` });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return findings;
|
|
37
|
+
}
|
|
38
|
+
/** Completion integrity for every gated plan at head: discharged ledgers, active Spec docs, their checklists, an Abandoned plan's backlog. */
|
|
39
|
+
export function ledgerRule(ctx, root) {
|
|
40
|
+
const findings = [];
|
|
41
|
+
const folder = plansFolder(root);
|
|
42
|
+
for (const path of markdownIn(ctx.head, folder)) {
|
|
43
|
+
const file = basenamePosix(path);
|
|
44
|
+
const text = ctx.head.read(path) ?? '';
|
|
45
|
+
if (!/^\*\*Gates:\*\*/m.test(text))
|
|
46
|
+
continue;
|
|
47
|
+
const status = /^\*\*Status:\*\*\s*(\S+)/m.exec(text)?.[1];
|
|
48
|
+
if (status === 'Complete') {
|
|
49
|
+
const docRows = undischargedDocRows(text);
|
|
50
|
+
if (docRows.length)
|
|
51
|
+
findings.push({ id: 'ledger.doc_rows_undischarged', path, message: `${file} is Complete with ${docRows.length} Doc Obligations row(s) missing a discharge outcome (${docRows.join(', ')})` });
|
|
52
|
+
const rows = undischargedRows(text);
|
|
53
|
+
if (rows.length)
|
|
54
|
+
findings.push({ id: 'ledger.test_rows_undischarged', path, message: `${file} is Complete with ${rows.length} Test Obligations row(s) missing a discharge outcome (#${rows.join(', #')})` });
|
|
55
|
+
for (const match of text.matchAll(/^\*\*Spec:\*\*\s*\[[^\]]*\]\(([^)]+)\)/gm)) {
|
|
56
|
+
const spec = match[1];
|
|
57
|
+
const specPath = joinPosix(folder, spec);
|
|
58
|
+
const specText = specPath === null ? '' : (ctx.head.read(specPath) ?? '');
|
|
59
|
+
const specStatus = String(field(frontmatter(specText), 'status') ?? '');
|
|
60
|
+
if (specText && specStatus !== 'active')
|
|
61
|
+
findings.push({ id: 'ledger.spec_not_active', path, message: `${file} is Complete but its Spec ${spec} is status: ${specStatus || 'unknown'} (expected active)` });
|
|
62
|
+
const feature = basenamePosix(spec).replace(/\.md$/, '');
|
|
63
|
+
if (ctx.head.read(`${root}/tests/${feature}.md`) === null)
|
|
64
|
+
findings.push({ id: 'ledger.checklist_missing', path, message: `${file} is Complete but ${root}/tests/${feature}.md (the behavioral checklist) is missing` });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (status === 'Abandoned' && !/backlog/i.test(text))
|
|
68
|
+
findings.push({ id: 'ledger.abandoned_without_backlog', path, message: `${file} is Abandoned without referencing the backlog` });
|
|
69
|
+
}
|
|
70
|
+
return findings;
|
|
71
|
+
}
|
|
72
|
+
/** Why a path is frozen in `tree` (a committed changelog entry, a Complete or Abandoned plan, an executed runbook), or null. */
|
|
73
|
+
export function frozenReason(tree, root, rel) {
|
|
74
|
+
if (rel.includes('.bkp.') || !rel.endsWith('.md'))
|
|
75
|
+
return null;
|
|
76
|
+
if (rel.startsWith(`${root}/history/changelog/`))
|
|
77
|
+
return tree.read(rel) !== null ? 'committed changelog entry' : null;
|
|
78
|
+
if (rel.startsWith(`${root}/history/plans/`))
|
|
79
|
+
return /^\*\*Status:\*\*\s*(Complete|Abandoned)\b/m.test(tree.read(rel) ?? '') ? 'plan with Status: Complete or Abandoned' : null;
|
|
80
|
+
if (rel.startsWith(`${root}/history/migrations/`))
|
|
81
|
+
return /^\*\*Executed:\*\*\s*\d{4}-\d{2}-\d{2}/m.test(tree.read(rel) ?? '') ? 'outcome-stamped migration runbook' : null;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/** A change to a record frozen at base. With no base tree nothing is committed, so nothing is frozen. */
|
|
85
|
+
export function frozenRecordRule(ctx, root) {
|
|
86
|
+
if (!ctx.base)
|
|
87
|
+
return [];
|
|
88
|
+
const findings = [];
|
|
89
|
+
for (const change of ctx.changes ?? []) {
|
|
90
|
+
for (const rel of changedPaths(change)) {
|
|
91
|
+
if (change.status === 'A' && rel === change.path)
|
|
92
|
+
continue;
|
|
93
|
+
const reason = frozenReason(ctx.base, root, rel);
|
|
94
|
+
if (reason)
|
|
95
|
+
findings.push({ id: 'frozen-record.modified', path: rel, message: `${rel} is frozen (${reason}); a correction is a new dated record that links back` });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return findings;
|
|
99
|
+
}
|
|
100
|
+
const LOCKFILES = /^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|Cargo\.lock)$/;
|
|
101
|
+
const HARNESS_PREFIXES = ['.claude/', '.agents/', '.codex/'];
|
|
102
|
+
const HARNESS_FILES = ['AGENTS.md', 'CLAUDE.md', '.mcp.json', '.vscode/settings.json', '.prettierignore', '.prettierrc.json'];
|
|
103
|
+
/** The changelog rule's closed exemption list, judged per file. */
|
|
104
|
+
export function changelogExemptFile(root, path) {
|
|
105
|
+
return (LOCKFILES.test(basenamePosix(path)) ||
|
|
106
|
+
HARNESS_PREFIXES.some((prefix) => path.startsWith(prefix)) ||
|
|
107
|
+
HARNESS_FILES.includes(path) ||
|
|
108
|
+
path.startsWith(`${root}/history/changelog/`) ||
|
|
109
|
+
path.includes('.bkp.') ||
|
|
110
|
+
path === `${root}/history/CHANGELOG.md`);
|
|
111
|
+
}
|
|
112
|
+
/** A non-exempt change set carries a changelog entry: bot authors, pure renames and exempt-only sets aside. */
|
|
113
|
+
export function changelogRule(ctx, root) {
|
|
114
|
+
const changes = ctx.changes ?? [];
|
|
115
|
+
if (changes.length === 0)
|
|
116
|
+
return [];
|
|
117
|
+
const isBot = /\[bot\]|actions@github\.com/i.test(ctx.commit?.author ?? '');
|
|
118
|
+
const allRenames = changes.every((change) => change.status === 'R');
|
|
119
|
+
const allExempt = changes.every((change) => changelogExemptFile(root, change.path));
|
|
120
|
+
const hasEntry = changes.some((change) => change.status !== 'D' && change.path.startsWith(`${root}/history/changelog/`) && change.path.endsWith('.md'));
|
|
121
|
+
if (isBot || allRenames || allExempt || hasEntry)
|
|
122
|
+
return [];
|
|
123
|
+
return [{ id: 'changelog.missing_entry', message: `the change set is not exempt and carries no ${root}/history/changelog/ entry` }];
|
|
124
|
+
}
|
|
125
|
+
/** A plan flipped Complete in the change set needs a changelog entry at head naming its slug. */
|
|
126
|
+
export function changelogEntryRule(ctx, root) {
|
|
127
|
+
const findings = [];
|
|
128
|
+
const complete = /^\*\*Status:\*\*\s*Complete\b/m;
|
|
129
|
+
const folder = `${root}/history/changelog`;
|
|
130
|
+
let entries = null;
|
|
131
|
+
for (const change of ctx.changes ?? []) {
|
|
132
|
+
if (change.status === 'D' || !change.path.startsWith(`${root}/history/plans/`) || !change.path.endsWith('.md'))
|
|
133
|
+
continue;
|
|
134
|
+
if (!complete.test(ctx.head.read(change.path) ?? ''))
|
|
135
|
+
continue;
|
|
136
|
+
if (ctx.base && complete.test(ctx.base.read(change.from ?? change.path) ?? ''))
|
|
137
|
+
continue;
|
|
138
|
+
const slug = basenamePosix(change.path).replace(/\.md$/, '');
|
|
139
|
+
entries ??= ctx.head
|
|
140
|
+
.list(`${folder}/`)
|
|
141
|
+
.filter((path) => path.endsWith('.md'))
|
|
142
|
+
.map((path) => ctx.head.read(path) ?? '');
|
|
143
|
+
if (!entries.some((text) => text.includes(slug)))
|
|
144
|
+
findings.push({ id: 'changelog-entry.missing', path: change.path, message: `${change.path} reached Status: Complete with no ${folder}/ entry naming ${slug}` });
|
|
145
|
+
}
|
|
146
|
+
return findings;
|
|
147
|
+
}
|
|
148
|
+
export const protectedListPath = (root) => `${root}/operations/protected-files.md`;
|
|
149
|
+
/** A changed path (or a rename's source) on the protected list, read from base so a change cannot drop a path and edit it at once. */
|
|
150
|
+
export function protectedFileRule(ctx, root) {
|
|
151
|
+
const listPath = protectedListPath(root);
|
|
152
|
+
const listed = new Set([listPath, ...backtickTokens((ctx.base ?? ctx.head).read(listPath) ?? '')]);
|
|
153
|
+
const findings = [];
|
|
154
|
+
for (const change of ctx.changes ?? []) {
|
|
155
|
+
for (const rel of changedPaths(change)) {
|
|
156
|
+
if (!listed.has(rel) || waiverCovers(ctx.waivers ?? [], 'protected-file', rel, ctx.today))
|
|
157
|
+
continue;
|
|
158
|
+
findings.push({ id: 'protected-file.modified', path: rel, message: `${rel} is on the protected list (${listPath})` });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return findings;
|
|
162
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// packages/harness/src/rules/context.ts - The rule library's shared shapes: the tree a rule reads, the change set,
|
|
2
|
+
// the context, the result, waivers and the session facts a hook supplies. Imports no node: module, so the Worker
|
|
3
|
+
// runs every rule. docs/architecture/systems/harness-records.md speaks for it (Status check and enforcement).
|
|
4
|
+
import { resolveDocsRoot } from './docsRoot.js';
|
|
5
|
+
export const docsRootOf = (ctx) => ctx.docsRoot?.trim().replace(/\/+$/, '') || resolveDocsRoot(ctx.head);
|
|
6
|
+
export const waiverExpiry = (row) => /expires=(\d{4}-\d{2}-\d{2})/.exec(row.body)?.[1] ?? null;
|
|
7
|
+
/** Unexpired waived rows for a finding kind. */
|
|
8
|
+
export function unexpiredWaivers(rows, kind, today) {
|
|
9
|
+
return rows.filter((row) => {
|
|
10
|
+
if (row.kind !== 'waived')
|
|
11
|
+
return false;
|
|
12
|
+
if (kind && !row.body.includes(kind))
|
|
13
|
+
return false;
|
|
14
|
+
const expires = waiverExpiry(row);
|
|
15
|
+
return !!expires && expires >= today;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** True when an unexpired waiver of `kind` names the subject in backticks; a bare substring never covers. */
|
|
19
|
+
export function waiverCovers(rows, kind, subject, today) {
|
|
20
|
+
return unexpiredWaivers(rows, kind, today).some((row) => row.body.includes(`\`${subject}\``));
|
|
21
|
+
}
|
|
22
|
+
/** Space-free backticked tokens in markdown, `./` stripped: the grammar of path-carrying prose. */
|
|
23
|
+
export function backtickTokens(text) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const match of String(text || '').matchAll(/`([^`\n]+)`/g)) {
|
|
26
|
+
const candidate = match[1].trim().replace(/^\.\//, '');
|
|
27
|
+
if (candidate && !candidate.includes(' '))
|
|
28
|
+
out.push(candidate);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** One definition of a source file: the plan gate and the drift detector agree on it. */
|
|
33
|
+
export const SOURCE_EXT = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|css|scss|vue|svelte)$/;
|
|
34
|
+
/** The markdown files directly inside a folder of the tree. */
|
|
35
|
+
export const markdownIn = (tree, folder) => tree
|
|
36
|
+
.list(`${folder}/`)
|
|
37
|
+
.filter((path) => path.endsWith('.md') && !path.slice(folder.length + 1).includes('/'))
|
|
38
|
+
.sort();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// packages/harness/src/rules/docsRoot.ts - The repository's docs root: `.nustack/workspace.json` docsRoot, else
|
|
2
|
+
// `.claude/harness.json` docsRoot, else `docs`. One function for the rules and the mount, applying the declaration
|
|
3
|
+
// rule the `project-contract` conformance seam binds every reader to. docs/architecture/systems/harness-records.md
|
|
4
|
+
// speaks for it.
|
|
5
|
+
export const DEFAULT_DOCS_ROOT = 'docs';
|
|
6
|
+
export const DOCS_ROOT_SOURCES = ['.nustack/workspace.json', '.claude/harness.json'];
|
|
7
|
+
/** A `docsRoot` value as a declaration: trimmed, trailing slashes stripped; empty, absolute or `..`-segmented is none. */
|
|
8
|
+
export function docsRootDeclaration(value) {
|
|
9
|
+
if (typeof value !== 'string')
|
|
10
|
+
return null;
|
|
11
|
+
const trimmed = value.trim().replace(/[\\/]+$/, '');
|
|
12
|
+
if (trimmed === '' || /^([A-Za-z]:)?[\\/]/.test(trimmed))
|
|
13
|
+
return null;
|
|
14
|
+
return trimmed.split(/[\\/]/).includes('..') ? null : trimmed;
|
|
15
|
+
}
|
|
16
|
+
function declaredRoot(text) {
|
|
17
|
+
if (!text)
|
|
18
|
+
return null;
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(text);
|
|
21
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? docsRootDeclaration(parsed.docsRoot) : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function resolveDocsRoot(tree) {
|
|
28
|
+
for (const source of DOCS_ROOT_SOURCES) {
|
|
29
|
+
const root = declaredRoot(tree.read(source));
|
|
30
|
+
if (root)
|
|
31
|
+
return root;
|
|
32
|
+
}
|
|
33
|
+
return DEFAULT_DOCS_ROOT;
|
|
34
|
+
}
|