@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
package/dist/program.js
CHANGED
|
@@ -30,6 +30,7 @@ import { runDocsList, runDocsShow, runDocsStatus } from "./commands/docs.js";
|
|
|
30
30
|
import { runHandoffList, runHandoffShow } from "./commands/handoff.js";
|
|
31
31
|
import { runToolkitStatus, runToolkitReconcile } from "./commands/toolkit.js";
|
|
32
32
|
import { runHarnessMount, runHarnessStatus } from "./commands/harness.js";
|
|
33
|
+
import { runCheck } from "./commands/check.js";
|
|
33
34
|
import { runSessionsList, runSessionsPrune, runSessionsRecord, runSessionsServe, } from "./commands/sessions.js";
|
|
34
35
|
import { registerReviewCommands } from "./commands/review.js";
|
|
35
36
|
import { registerRecordsCommands, startDrainIfDue } from "./commands/records.js";
|
|
@@ -38,6 +39,7 @@ import { RECORD_ALIAS_GROUPS, registerRecordAliases } from "./commands/recordAli
|
|
|
38
39
|
import { runAgentContext, runAgentHookInstall, runAgentHookRemove } from "./commands/agent.js";
|
|
39
40
|
import { runCensus } from "./commands/census.js";
|
|
40
41
|
import { runGateInstall, runGateRemove, runGateStatus } from "./commands/gate.js";
|
|
42
|
+
import { parseBudgetMs, parseLineWindow, runReadBulk } from "./commands/read.js";
|
|
41
43
|
import { HOOK_SUBCOMMANDS } from "./lib/gate/subcommands.js";
|
|
42
44
|
import { printJsonError } from "./lib/json_output.js";
|
|
43
45
|
import { setCliVersion } from "./lib/auth_grant.js";
|
|
@@ -68,7 +70,9 @@ program
|
|
|
68
70
|
.command("init")
|
|
69
71
|
.description("Onboard this folder: sign in, then bind it to a Project (the onboarding router)")
|
|
70
72
|
.option("--organization <id>", "the Organization to create or select the Project in")
|
|
73
|
+
.option("--project <id>", "the Project to bind (with --yes and --organization: the unattended path)")
|
|
71
74
|
.option("--yes", "pre-confirm the populated-folder onboarding guard")
|
|
75
|
+
.option("--json", "emit the versioned machine envelope (unattended path only)")
|
|
72
76
|
.action(async (options) => runInit({ ...program.opts(), ...options }));
|
|
73
77
|
program
|
|
74
78
|
.command("add [service]")
|
|
@@ -244,8 +248,8 @@ registerProjectSyncCommands(project, () => program.opts());
|
|
|
244
248
|
registerReviewCommands(program.command("review").description("Read and mirror NuStack Review items"), () => program.opts());
|
|
245
249
|
registerRecordsCommands(program.command("records").description("Emit, send and list harness records"), () => program.opts());
|
|
246
250
|
registerRecordAliases(program, () => program.opts());
|
|
247
|
-
const LOCAL_ONLY_COMMANDS = ["census"];
|
|
248
|
-
const UNDRAINED_GROUPS = ["records", "gate", ...RECORD_ALIAS_GROUPS];
|
|
251
|
+
const LOCAL_ONLY_COMMANDS = ["census", "check"];
|
|
252
|
+
const UNDRAINED_GROUPS = ["records", "gate", "read", ...RECORD_ALIAS_GROUPS];
|
|
249
253
|
program.hook("preAction", async (_program, action) => {
|
|
250
254
|
if (program.opts().nonInteractive)
|
|
251
255
|
process.env[NON_INTERACTIVE_VARIABLE] = "1";
|
|
@@ -257,6 +261,13 @@ program.hook("preAction", async (_program, action) => {
|
|
|
257
261
|
if (action.name() !== "telemetry" && isPartCheckStale())
|
|
258
262
|
startTelemetryReconcile({ nustackUrl: program.opts().nustackUrl });
|
|
259
263
|
});
|
|
264
|
+
program
|
|
265
|
+
.command("check [rule]")
|
|
266
|
+
.description("Run one harness rule over this checkout; exits 0 pass, 1 block or flag, 2 when it cannot run")
|
|
267
|
+
.option("--json", "emit the versioned machine envelope")
|
|
268
|
+
.option("--hook <event>", "payload mode for the seed's thin hooks: pre-tool-use or stop (reads the hook payload on stdin)")
|
|
269
|
+
.option("--protection", "with --hook: the Protection wall's entry (block-rung Protection rows only)")
|
|
270
|
+
.action(async (rule, options) => runCheck(rule, options));
|
|
260
271
|
const docs = program.command("docs").description("Work with a Project's Documents");
|
|
261
272
|
docs
|
|
262
273
|
.command("list")
|
|
@@ -321,12 +332,13 @@ harness
|
|
|
321
332
|
.command("mount [path]")
|
|
322
333
|
.description("Mount the resolved harness version into a checkout (default: the current repository)")
|
|
323
334
|
.option("--project <id>", "the Project to act as (defaults to the bound repository)")
|
|
324
|
-
.option("--
|
|
335
|
+
.option("--runtime <runtime>", "the runtime whose half of the version is mounted (claude-code, codex, opencode, copilot-cli)")
|
|
336
|
+
.option("--write-contract <mode>", "whether the mount may write CLAUDE.md / AGENTS.md (never, when-unmodified)")
|
|
325
337
|
.option("--json", "emit the versioned machine envelope")
|
|
326
338
|
.action(async (target, options) => runHarnessMount(target, options, program.opts()));
|
|
327
339
|
harness
|
|
328
340
|
.command("status")
|
|
329
|
-
.description("Print the mounted harness against the
|
|
341
|
+
.description("Print the mounted harness against the expected version; exits 1 when they differ, 2 when unreachable")
|
|
330
342
|
.option("--project <id>", "the Project to act as (defaults to the bound repository)")
|
|
331
343
|
.option("--json", "emit the versioned machine envelope")
|
|
332
344
|
.action(async (options) => runHarnessStatus(options, program.opts()));
|
|
@@ -385,6 +397,17 @@ gate.command("status")
|
|
|
385
397
|
.description("Report the gate's hooks, the Local Reader switch, the threshold, the outline and the store version")
|
|
386
398
|
.option("--json", "emit the versioned machine envelope")
|
|
387
399
|
.action(async (options) => runGateStatus(options));
|
|
400
|
+
const read = program.command("read").description("Ask the local model about large files through the running NuStack desktop");
|
|
401
|
+
read.command("bulk")
|
|
402
|
+
.description("Ask the local model one question about a few large files; a digest that fails its check is never printed")
|
|
403
|
+
.requiredOption("--paths <path...>", "the files to read, at most eight, inside one NuStack project")
|
|
404
|
+
.requiredOption("--question <text>", "what to answer about those files")
|
|
405
|
+
.option("--session <id>", "the Claude Code session this read belongs to (its latch and label rows); omitted, the read runs as `direct`")
|
|
406
|
+
.option("--agent-type <type>", "the sub-agent type the label rows name (default `main`)")
|
|
407
|
+
.option("--lines <n>", "line window per model turn (the desktop's default 400)", parseLineWindow)
|
|
408
|
+
.option("--budget-ms <n>", "the whole call's wall-time budget, 5000 to 300000 ms (default: NUSTACK_READ_BULK_BUDGET_MS, the desktop setting, then the desktop's own)", parseBudgetMs)
|
|
409
|
+
.option("--json", "emit the versioned machine envelope")
|
|
410
|
+
.action(async (options) => runReadBulk(options));
|
|
388
411
|
function describeError(error) {
|
|
389
412
|
const directCode = error?.code;
|
|
390
413
|
if (directCode === "PROJECT_CONTEXT_MISSING") {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// packages/harness/src/assemble/runtimes.ts - The four runtimes, each one's instruction file and merge targets, the
|
|
2
|
+
// docs-root token and the `<runtime>/<repository path>` manifest convention. Imports nothing at runtime, so the
|
|
3
|
+
// mount's vendored closure can carry it. docs/architecture/systems/harness-compiler.md speaks for it (Compile).
|
|
4
|
+
export const RUNTIMES = ['claude-code', 'codex', 'opencode', 'copilot-cli'];
|
|
5
|
+
export const isRuntime = (value) => RUNTIMES.includes(value);
|
|
6
|
+
/** The contract path of each runtime: its instruction file, the only file a mount treats as committed policy. */
|
|
7
|
+
export const INSTRUCTION_FILE_OF = {
|
|
8
|
+
'claude-code': 'CLAUDE.md',
|
|
9
|
+
codex: 'AGENTS.md',
|
|
10
|
+
opencode: 'AGENTS.md',
|
|
11
|
+
'copilot-cli': '.github/copilot-instructions.md',
|
|
12
|
+
};
|
|
13
|
+
/** The repository file each merge kind folds into. */
|
|
14
|
+
export const MERGE_TARGET_OF = {
|
|
15
|
+
'claude-settings': '.claude/settings.local.json',
|
|
16
|
+
'codex-hooks': '.codex/hooks.json',
|
|
17
|
+
'mcp-servers': '.mcp.json',
|
|
18
|
+
};
|
|
19
|
+
/** A `seed` path is `@docs/<path>`, relative to the repository's docs root, which the mount resolves. */
|
|
20
|
+
export const DOCS_ROOT_TOKEN = '@docs';
|
|
21
|
+
export function prefixRuntime(runtime, files) {
|
|
22
|
+
return files.map((file) => ({ ...file, path: `${runtime}/${file.path}` }));
|
|
23
|
+
}
|
|
24
|
+
/** `<runtime>/<path>` split into its two halves; null when the first segment is not a runtime. */
|
|
25
|
+
export function splitRuntimePath(path) {
|
|
26
|
+
const slash = path.indexOf('/');
|
|
27
|
+
if (slash <= 0)
|
|
28
|
+
return null;
|
|
29
|
+
const runtime = path.slice(0, slash);
|
|
30
|
+
const rest = path.slice(slash + 1);
|
|
31
|
+
return isRuntime(runtime) && rest.length > 0 ? { runtime, path: rest } : null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const clone = (value) => (value === undefined ? value : JSON.parse(JSON.stringify(value)));
|
|
2
|
+
/** Structural equality over JSON values, key order ignored. */
|
|
3
|
+
export function jsonEqual(a, b) {
|
|
4
|
+
if (a === b)
|
|
5
|
+
return true;
|
|
6
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
|
|
7
|
+
return false;
|
|
8
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
9
|
+
return false;
|
|
10
|
+
if (Array.isArray(a))
|
|
11
|
+
return a.length === b.length && a.every((item, index) => jsonEqual(item, b[index]));
|
|
12
|
+
const left = Object.keys(a);
|
|
13
|
+
const right = Object.keys(b);
|
|
14
|
+
return left.length === right.length && left.every((key) => jsonEqual(a[key], b[key]));
|
|
15
|
+
}
|
|
16
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
const matcherOf = (group) => (typeof group.matcher === 'string' ? group.matcher : null);
|
|
18
|
+
function removeManagedHooks(hooks, managed) {
|
|
19
|
+
for (const entry of managed) {
|
|
20
|
+
const groups = hooks[entry.event];
|
|
21
|
+
if (!Array.isArray(groups))
|
|
22
|
+
continue;
|
|
23
|
+
const group = groups.find((candidate) => matcherOf(candidate) === entry.matcher);
|
|
24
|
+
if (!group || !Array.isArray(group.hooks))
|
|
25
|
+
continue;
|
|
26
|
+
const index = group.hooks.findIndex((hook) => jsonEqual(hook, entry.hook));
|
|
27
|
+
if (index >= 0)
|
|
28
|
+
group.hooks.splice(index, 1);
|
|
29
|
+
}
|
|
30
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
31
|
+
if (!Array.isArray(groups))
|
|
32
|
+
continue;
|
|
33
|
+
hooks[event] = groups.filter((group) => !Array.isArray(group.hooks) || group.hooks.length > 0 || Object.keys(group).some((key) => key !== 'hooks' && key !== 'matcher'));
|
|
34
|
+
if (hooks[event].length === 0)
|
|
35
|
+
delete hooks[event];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function addHooks(hooks, incoming) {
|
|
39
|
+
const added = [];
|
|
40
|
+
for (const [event, groups] of Object.entries(incoming)) {
|
|
41
|
+
const existing = (hooks[event] = Array.isArray(hooks[event]) ? hooks[event] : []);
|
|
42
|
+
for (const group of groups) {
|
|
43
|
+
const matcher = matcherOf(group);
|
|
44
|
+
let target = existing.find((candidate) => matcherOf(candidate) === matcher);
|
|
45
|
+
if (!target) {
|
|
46
|
+
target = { ...clone(group), hooks: [] };
|
|
47
|
+
existing.push(target);
|
|
48
|
+
}
|
|
49
|
+
target.hooks = Array.isArray(target.hooks) ? target.hooks : [];
|
|
50
|
+
for (const hook of group.hooks ?? []) {
|
|
51
|
+
if (target.hooks.some((held) => jsonEqual(held, hook)))
|
|
52
|
+
continue;
|
|
53
|
+
target.hooks.push(clone(hook));
|
|
54
|
+
added.push({ event, matcher, hook: clone(hook) });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return added;
|
|
59
|
+
}
|
|
60
|
+
function mergeHookFile(existing, fragment, priorManaged) {
|
|
61
|
+
const merged = clone(existing ?? {});
|
|
62
|
+
const hooks = isObject(merged.hooks) ? merged.hooks : {};
|
|
63
|
+
removeManagedHooks(hooks, priorManaged?.hooks ?? []);
|
|
64
|
+
const added = addHooks(hooks, isObject(fragment.hooks) ? fragment.hooks : {});
|
|
65
|
+
if (Object.keys(hooks).length > 0 || isObject(merged.hooks))
|
|
66
|
+
merged.hooks = hooks;
|
|
67
|
+
return { merged, hooks: added };
|
|
68
|
+
}
|
|
69
|
+
/** `.claude/settings.local.json`: hooks by event and matcher, permission rules by list, any other top-level key set only when absent. */
|
|
70
|
+
export function mergeClaudeSettings(existing, fragment, priorManaged) {
|
|
71
|
+
const { merged, hooks } = mergeHookFile(existing, fragment, priorManaged);
|
|
72
|
+
const permissions = [];
|
|
73
|
+
const held = isObject(merged.permissions) ? merged.permissions : null;
|
|
74
|
+
if (held) {
|
|
75
|
+
for (const { list, rule } of priorManaged?.permissions ?? []) {
|
|
76
|
+
const rules = held[list];
|
|
77
|
+
if (Array.isArray(rules))
|
|
78
|
+
held[list] = rules.filter((candidate) => candidate !== rule);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const incoming = isObject(fragment.permissions) ? fragment.permissions : {};
|
|
82
|
+
for (const [list, rules] of Object.entries(incoming)) {
|
|
83
|
+
if (!Array.isArray(rules))
|
|
84
|
+
continue;
|
|
85
|
+
const target = (merged.permissions = isObject(merged.permissions) ? merged.permissions : {});
|
|
86
|
+
const current = (target[list] = Array.isArray(target[list]) ? target[list] : []);
|
|
87
|
+
for (const rule of rules) {
|
|
88
|
+
if (typeof rule !== 'string' || current.includes(rule))
|
|
89
|
+
continue;
|
|
90
|
+
current.push(rule);
|
|
91
|
+
permissions.push({ list, rule });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (isObject(merged.permissions)) {
|
|
95
|
+
const target = merged.permissions;
|
|
96
|
+
for (const [list, rules] of Object.entries(target))
|
|
97
|
+
if (Array.isArray(rules) && rules.length === 0 && !(existing && isObject(existing.permissions) && Array.isArray(existing.permissions[list])))
|
|
98
|
+
delete target[list];
|
|
99
|
+
if (Object.keys(target).length === 0 && !(existing && isObject(existing.permissions)))
|
|
100
|
+
delete merged.permissions;
|
|
101
|
+
}
|
|
102
|
+
const keys = [];
|
|
103
|
+
for (const { key, value } of priorManaged?.keys ?? [])
|
|
104
|
+
if (key in merged && jsonEqual(merged[key], value))
|
|
105
|
+
delete merged[key];
|
|
106
|
+
for (const [key, value] of Object.entries(fragment)) {
|
|
107
|
+
if (key === 'hooks' || key === 'permissions' || key in merged)
|
|
108
|
+
continue;
|
|
109
|
+
merged[key] = clone(value);
|
|
110
|
+
keys.push({ key, value: clone(value) });
|
|
111
|
+
}
|
|
112
|
+
return { merged, managed: { ...(hooks.length ? { hooks } : {}), ...(permissions.length ? { permissions } : {}), ...(keys.length ? { keys } : {}) } };
|
|
113
|
+
}
|
|
114
|
+
/** `.codex/hooks.json`: hooks by event and matcher, every other key left as the person wrote it. */
|
|
115
|
+
export function mergeCodexHooks(existing, fragment, priorManaged) {
|
|
116
|
+
const { merged, hooks } = mergeHookFile(existing, fragment, priorManaged);
|
|
117
|
+
return { merged, managed: hooks.length ? { hooks } : {} };
|
|
118
|
+
}
|
|
119
|
+
/** `.mcp.json`: servers by name; a name the person holds is theirs, and a server the previous mount added is replaced. */
|
|
120
|
+
export function mergeMcpServers(existing, fragment, priorManaged) {
|
|
121
|
+
const merged = clone(existing ?? {});
|
|
122
|
+
const servers = (isObject(merged.mcpServers) ? merged.mcpServers : {});
|
|
123
|
+
for (const { name, value } of priorManaged?.mcpServers ?? [])
|
|
124
|
+
if (name in servers && jsonEqual(servers[name], value))
|
|
125
|
+
delete servers[name];
|
|
126
|
+
const added = [];
|
|
127
|
+
for (const [name, value] of Object.entries(isObject(fragment.mcpServers) ? fragment.mcpServers : {})) {
|
|
128
|
+
if (name in servers)
|
|
129
|
+
continue;
|
|
130
|
+
servers[name] = clone(value);
|
|
131
|
+
added.push({ name, value: clone(value) });
|
|
132
|
+
}
|
|
133
|
+
if (Object.keys(servers).length > 0 || isObject(merged.mcpServers))
|
|
134
|
+
merged.mcpServers = servers;
|
|
135
|
+
return { merged, managed: added.length ? { mcpServers: added } : {} };
|
|
136
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// packages/harness/src/digest/index.ts - The one content digest a harness version is identified by, its test
|
|
2
|
+
// vectors, and the version manifest. Imports only node:crypto, which the Worker's nodejs_compat flag provides.
|
|
3
|
+
// docs/architecture/systems/harness-compiler.md speaks for it (Versions).
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { byPath } from '../lib/paths.js';
|
|
6
|
+
export { FINDING_CODES } from '../lib/findings.js';
|
|
7
|
+
export const sha256Hex = (content) => createHash('sha256').update(content).digest('hex');
|
|
8
|
+
const shaOf = (file) => ('sha256' in file && typeof file.sha256 === 'string' ? file.sha256 : sha256Hex(file.content));
|
|
9
|
+
/** sha256 hex of JSON.stringify of [{path, sha256}] sorted by path in code-unit order, each sha256 over the file's UTF-8 bytes. */
|
|
10
|
+
export function contentDigest(files) {
|
|
11
|
+
const entries = files.map((file) => ({ path: file.path, sha256: shaOf(file) })).sort(byPath);
|
|
12
|
+
return sha256Hex(JSON.stringify(entries));
|
|
13
|
+
}
|
|
14
|
+
/** The two vectors every copy of the digest definition pins against. */
|
|
15
|
+
export const CONTENT_DIGEST_VECTORS = [
|
|
16
|
+
{ files: [], digest: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945' },
|
|
17
|
+
{ files: [{ path: 'a', content: 'x' }], digest: 'd19d4b514b0cbad688ef3d00ee838c4db50655f1306c93c9ce84354b96e1a752' },
|
|
18
|
+
];
|
|
19
|
+
const byteLength = (content) => (typeof content === 'string' ? new TextEncoder().encode(content).byteLength : content.byteLength);
|
|
20
|
+
/** The manifest of one version: prefixed entries sorted by path, totals, and the digest over its own entries. */
|
|
21
|
+
export function versionManifest(files, meta) {
|
|
22
|
+
const entries = files
|
|
23
|
+
.map((file) => ({ path: file.path, sha256: sha256Hex(file.content), byteSize: byteLength(file.content), policy: file.policy, ...(file.merge ? { merge: file.merge } : {}) }))
|
|
24
|
+
.sort(byPath);
|
|
25
|
+
return {
|
|
26
|
+
schemaVersion: 1,
|
|
27
|
+
digest: contentDigest(entries),
|
|
28
|
+
semver: meta.semver,
|
|
29
|
+
projectId: meta.projectId,
|
|
30
|
+
catalogVersion: meta.catalogVersion,
|
|
31
|
+
compileMode: meta.compileMode,
|
|
32
|
+
runtimes: [...meta.runtimes],
|
|
33
|
+
unsupported: meta.unsupported,
|
|
34
|
+
fileCount: entries.length,
|
|
35
|
+
byteSize: entries.reduce((total, entry) => total + entry.byteSize, 0),
|
|
36
|
+
files: entries,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// packages/harness/src/lib/findings.ts - FINDING_CODES: every mount.*, compile.* and assemble.* finding code, in one
|
|
2
|
+
// list each subpath re-exports. docs/architecture/systems/harness-compiler.md and harness-records.md speak for it.
|
|
3
|
+
export const FINDING_CODES = [
|
|
4
|
+
'assemble.unknown_kind',
|
|
5
|
+
'assemble.unsafe_path',
|
|
6
|
+
'assemble.missing_primary',
|
|
7
|
+
'assemble.missing_component',
|
|
8
|
+
'assemble.duplicate_path',
|
|
9
|
+
'assemble.unparsable_json',
|
|
10
|
+
'compile.no_model',
|
|
11
|
+
'compile.model_failed',
|
|
12
|
+
'compile.anchor_missing',
|
|
13
|
+
'compile.frontmatter_changed',
|
|
14
|
+
'compile.policy_over_budget',
|
|
15
|
+
'compile.skill_catalog_over_budget',
|
|
16
|
+
'mount.schema_unknown',
|
|
17
|
+
'mount.runtime_unknown',
|
|
18
|
+
'mount.bad_sha256',
|
|
19
|
+
'mount.unsafe_path',
|
|
20
|
+
'mount.contract_outside',
|
|
21
|
+
'mount.digest_mismatch',
|
|
22
|
+
'mount.expected_digest_mismatch',
|
|
23
|
+
'mount.object_mismatch',
|
|
24
|
+
'mount.cache_corrupt',
|
|
25
|
+
'mount.fetch_failed',
|
|
26
|
+
'mount.no_route',
|
|
27
|
+
'mount.project_unknown',
|
|
28
|
+
'mount.contract_edited',
|
|
29
|
+
'mount.kept_edited',
|
|
30
|
+
'mount.merge_target_tracked',
|
|
31
|
+
'mount.merge_target_unparsable',
|
|
32
|
+
];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// packages/harness/src/lib/frontmatter.ts - The leading frontmatter block and one key's value, ported from etna
|
|
2
|
+
// harness/code/hooks/lib/ownership.mjs (frontmatter, unquote, cleanItem, field, asList). Pure, so the Worker runs it.
|
|
3
|
+
// docs/architecture/systems/harness-records.md speaks for it.
|
|
4
|
+
/** The leading `--- … ---` frontmatter block's body; "" when the text has none. */
|
|
5
|
+
export function frontmatter(text) {
|
|
6
|
+
const match = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
7
|
+
return match ? match[1] : '';
|
|
8
|
+
}
|
|
9
|
+
/** The frontmatter block with its fences and the line break after them, byte for byte; "" when there is none. */
|
|
10
|
+
export function frontmatterBlock(text) {
|
|
11
|
+
const match = /^?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.exec(text);
|
|
12
|
+
return match ? match[0] : '';
|
|
13
|
+
}
|
|
14
|
+
/** The text after the frontmatter block. */
|
|
15
|
+
export const bodyOf = (text) => text.slice(frontmatterBlock(text).length);
|
|
16
|
+
export function unquote(value) {
|
|
17
|
+
return String(value).trim().replace(/^["']|["']$/g, '').trim();
|
|
18
|
+
}
|
|
19
|
+
/** One YAML list item: a quoted item ends at its closing quote; an unquoted one ends at ` #`. */
|
|
20
|
+
export function cleanItem(value) {
|
|
21
|
+
const text = String(value).trim();
|
|
22
|
+
const quote = text[0];
|
|
23
|
+
if (quote === '"' || quote === "'") {
|
|
24
|
+
const end = text.indexOf(quote, 1);
|
|
25
|
+
if (end > 0)
|
|
26
|
+
return text.slice(1, end).trim();
|
|
27
|
+
}
|
|
28
|
+
const hash = text.search(/\s#/);
|
|
29
|
+
return unquote(hash >= 0 ? text.slice(0, hash) : text);
|
|
30
|
+
}
|
|
31
|
+
function parseInlineArray(raw) {
|
|
32
|
+
const end = raw.indexOf(']');
|
|
33
|
+
const body = (end >= 0 ? raw.slice(0, end) : raw).replace(/^\[/, '');
|
|
34
|
+
return body
|
|
35
|
+
.split(',')
|
|
36
|
+
.map((item) => cleanItem(item))
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
/** One frontmatter key: a scalar, an inline array, or a following `- item` block; undefined when absent. */
|
|
40
|
+
export function field(block, key) {
|
|
41
|
+
const lines = block.split(/\r?\n/);
|
|
42
|
+
const head = new RegExp(`^${key}\\s*:\\s*(.*)$`);
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
const match = head.exec(lines[i]);
|
|
45
|
+
if (!match)
|
|
46
|
+
continue;
|
|
47
|
+
const raw = match[1].trim();
|
|
48
|
+
if (raw === '') {
|
|
49
|
+
const next = (lines[i + 1] ?? '').trim();
|
|
50
|
+
if (next.startsWith('[')) {
|
|
51
|
+
let joined = '';
|
|
52
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
53
|
+
joined += (joined ? ' ' : '') + lines[j].trim();
|
|
54
|
+
if (lines[j].includes(']'))
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
return parseInlineArray(joined);
|
|
58
|
+
}
|
|
59
|
+
const items = [];
|
|
60
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
61
|
+
const item = /^\s*-\s+(.*)$/.exec(lines[j]);
|
|
62
|
+
if (!item)
|
|
63
|
+
break;
|
|
64
|
+
items.push(cleanItem(item[1]));
|
|
65
|
+
}
|
|
66
|
+
return items;
|
|
67
|
+
}
|
|
68
|
+
if (raw.startsWith('['))
|
|
69
|
+
return parseInlineArray(raw);
|
|
70
|
+
return unquote(raw);
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
/** A scalar, list or absent field as a clean string list. */
|
|
75
|
+
export function asList(value) {
|
|
76
|
+
if (value == null)
|
|
77
|
+
return [];
|
|
78
|
+
return (Array.isArray(value) ? value : [value]).map((item) => String(item).trim()).filter(Boolean);
|
|
79
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// packages/harness/src/lib/paths.ts - The one spelling of a relative path the package accepts, and the posix
|
|
2
|
+
// helpers the Worker-safe modules use instead of node:path. docs/architecture/systems/harness-compiler.md speaks for it.
|
|
3
|
+
/** True for a non-empty `/`-joined relative path: no leading slash, drive letter or backslash, no `.`, `..` or empty segment. */
|
|
4
|
+
export function isSafeRelativePath(path) {
|
|
5
|
+
if (typeof path !== 'string' || path.length === 0 || path.length > 1024)
|
|
6
|
+
return false;
|
|
7
|
+
if (path.includes('\\') || path.includes('\0') || path.startsWith('/') || /^[A-Za-z]:/.test(path))
|
|
8
|
+
return false;
|
|
9
|
+
return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
10
|
+
}
|
|
11
|
+
/** Resolves `relative` against the folder `base` (both `/`-joined, repository-relative); null when it climbs out. */
|
|
12
|
+
export function joinPosix(base, relative) {
|
|
13
|
+
const parts = base === '' ? [] : base.split('/');
|
|
14
|
+
for (const segment of relative.split('/')) {
|
|
15
|
+
if (segment === '' || segment === '.')
|
|
16
|
+
continue;
|
|
17
|
+
if (segment === '..') {
|
|
18
|
+
if (parts.length === 0)
|
|
19
|
+
return null;
|
|
20
|
+
parts.pop();
|
|
21
|
+
}
|
|
22
|
+
else
|
|
23
|
+
parts.push(segment);
|
|
24
|
+
}
|
|
25
|
+
return parts.join('/');
|
|
26
|
+
}
|
|
27
|
+
export const dirnamePosix = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
|
|
28
|
+
export const basenamePosix = (path) => path.slice(path.lastIndexOf('/') + 1);
|
|
29
|
+
export const depthOf = (path) => path.split('/').length;
|
|
30
|
+
/** Sorts by path in code-unit order, the order the content digest is defined over. */
|
|
31
|
+
export const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// packages/harness/src/mount/cache.ts - The machine's digest-keyed harness cache: `<cacheDir>/<digest>/files/<prefixed
|
|
2
|
+
// path>` plus `<cacheDir>/<digest>/manifest.json`, written last as the completeness mark. Every read is verified;
|
|
3
|
+
// a folder that fails is evicted. docs/architecture/systems/harness-records.md speaks for it (Offline mount).
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { readdirSync, rmSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { readBytes, writeAtomic } from './files.js';
|
|
8
|
+
export const HARNESS_CACHE_DIR_ENV = 'NUSTACK_HARNESS_CACHE_DIR';
|
|
9
|
+
const DIGEST_NAME = /^[0-9a-f]{64}$/;
|
|
10
|
+
/** The machine cache every repository shares: NUSTACK_HARNESS_CACHE_DIR, else ~/.nustack/cache/harness. The only reader of the variable. */
|
|
11
|
+
export function defaultHarnessCacheDir(env = process.env) {
|
|
12
|
+
return env[HARNESS_CACHE_DIR_ENV]?.trim() || join(homedir(), '.nustack', 'cache', 'harness');
|
|
13
|
+
}
|
|
14
|
+
export const cacheFolder = (cacheDir, digest) => join(cacheDir, digest);
|
|
15
|
+
const manifestFile = (cacheDir, digest) => join(cacheFolder(cacheDir, digest), 'manifest.json');
|
|
16
|
+
const objectFile = (cacheDir, digest, entry) => join(cacheFolder(cacheDir, digest), 'files', ...entry.path.split('/'));
|
|
17
|
+
/** The folder's manifest, or null when the folder is half-written (no manifest) or the file does not parse. */
|
|
18
|
+
export function readCachedManifest(cacheDir, digest) {
|
|
19
|
+
const bytes = readBytes(manifestFile(cacheDir, digest));
|
|
20
|
+
if (!bytes)
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(bytes.toString('utf8'));
|
|
24
|
+
return parsed && typeof parsed === 'object' && Array.isArray(parsed.files) ? parsed : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** A cached object's bytes, unverified; null when the folder lacks it. */
|
|
31
|
+
export const readCachedObject = (cacheDir, digest, entry) => readBytes(objectFile(cacheDir, digest, entry));
|
|
32
|
+
export function writeCachedObject(cacheDir, digest, entry, bytes) {
|
|
33
|
+
writeAtomic(objectFile(cacheDir, digest, entry), bytes);
|
|
34
|
+
}
|
|
35
|
+
/** Writes the completeness mark last, keeping the first `cachedAt` and the union of cached runtimes. */
|
|
36
|
+
export function writeCachedManifest(cacheDir, manifest, runtime) {
|
|
37
|
+
const prior = readCachedManifest(cacheDir, manifest.digest);
|
|
38
|
+
const cachedRuntimes = [...new Set([...(prior?.cachedRuntimes ?? []), runtime])].sort();
|
|
39
|
+
const body = { ...manifest, cachedAt: prior?.cachedAt ?? new Date().toISOString(), cachedRuntimes };
|
|
40
|
+
writeAtomic(manifestFile(cacheDir, manifest.digest), `${JSON.stringify(body, null, 2)}\n`);
|
|
41
|
+
}
|
|
42
|
+
export function evictCacheFolder(cacheDir, digest) {
|
|
43
|
+
rmSync(cacheFolder(cacheDir, digest), { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
/** The newest cached version for a project: 64-hex folders holding a complete manifest of that project, latest cachedAt. */
|
|
46
|
+
export function newestCachedDigest(cacheDir, projectId) {
|
|
47
|
+
let names;
|
|
48
|
+
try {
|
|
49
|
+
names = readdirSync(cacheDir);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
let best = null;
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
if (!DIGEST_NAME.test(name))
|
|
57
|
+
continue;
|
|
58
|
+
const manifest = readCachedManifest(cacheDir, name);
|
|
59
|
+
if (!manifest || manifest.projectId !== projectId || typeof manifest.cachedAt !== 'string')
|
|
60
|
+
continue;
|
|
61
|
+
if (!best || manifest.cachedAt > best.cachedAt)
|
|
62
|
+
best = { digest: name, semver: String(manifest.semver ?? ''), cachedAt: manifest.cachedAt };
|
|
63
|
+
}
|
|
64
|
+
return best ? { digest: best.digest, semver: best.semver } : null;
|
|
65
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// packages/harness/src/mount/exclude.ts - Keeps the mounted harness out of git without editing a tracked file: the
|
|
2
|
+
// managed block in the repository's info/exclude (the git directory followed through a worktree's gitdir and
|
|
3
|
+
// commondir), and the one git call that tells a tracked merge target from an untracked one.
|
|
4
|
+
// docs/architecture/systems/harness-records.md speaks for it (Harness mount).
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
8
|
+
import { writeAtomic } from './files.js';
|
|
9
|
+
export const MOUNT_EXCLUDE_START = '# nustack:mount:start';
|
|
10
|
+
export const MOUNT_EXCLUDE_END = '# nustack:mount:end';
|
|
11
|
+
/** The repository's git directory and its common directory (the same folder outside a worktree); null outside git. */
|
|
12
|
+
export function resolveGitDirs(root) {
|
|
13
|
+
const dotGit = join(root, '.git');
|
|
14
|
+
let gitDir;
|
|
15
|
+
try {
|
|
16
|
+
if (statSync(dotGit).isDirectory())
|
|
17
|
+
gitDir = dotGit;
|
|
18
|
+
else {
|
|
19
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, 'utf8'));
|
|
20
|
+
if (!pointer)
|
|
21
|
+
return null;
|
|
22
|
+
gitDir = resolve(root, pointer[1].trim());
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
let commonDir = gitDir;
|
|
29
|
+
try {
|
|
30
|
+
const common = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
|
|
31
|
+
if (common)
|
|
32
|
+
commonDir = isAbsolute(common) ? common : resolve(gitDir, common);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* no commondir: not a linked worktree */
|
|
36
|
+
}
|
|
37
|
+
return { gitDir, commonDir };
|
|
38
|
+
}
|
|
39
|
+
/** The block's lines: a folder per skill or hook library, a file per agent, rule or merge target. */
|
|
40
|
+
export function excludeBlockLines(paths) {
|
|
41
|
+
const names = new Set();
|
|
42
|
+
for (const path of paths) {
|
|
43
|
+
const parts = path.split('/');
|
|
44
|
+
names.add(parts.length > 3 ? `${parts.slice(0, 3).join('/')}/` : path);
|
|
45
|
+
}
|
|
46
|
+
return [MOUNT_EXCLUDE_START, '# Managed by nustack: the mounted harness, rewritten on every mount. Edit outside this block.', ...[...names].sort(), MOUNT_EXCLUDE_END];
|
|
47
|
+
}
|
|
48
|
+
/** Rewrites the managed block in place (appended when absent), leaving every line outside it untouched. False outside git. */
|
|
49
|
+
export function writeExcludeBlock(root, paths) {
|
|
50
|
+
const dirs = resolveGitDirs(root);
|
|
51
|
+
if (!dirs)
|
|
52
|
+
return false;
|
|
53
|
+
const file = join(dirs.commonDir, 'info', 'exclude');
|
|
54
|
+
const before = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
55
|
+
const lines = before === '' ? [] : before.replace(/\n$/, '').split('\n');
|
|
56
|
+
const start = lines.findIndex((line) => line.trim() === MOUNT_EXCLUDE_START);
|
|
57
|
+
const end = start >= 0 ? lines.findIndex((line, index) => index > start && line.trim() === MOUNT_EXCLUDE_END) : -1;
|
|
58
|
+
const block = excludeBlockLines(paths);
|
|
59
|
+
const next = start >= 0 && end > start ? [...lines.slice(0, start), ...block, ...lines.slice(end + 1)] : [...lines, ...block];
|
|
60
|
+
const after = `${next.join('\n')}\n`;
|
|
61
|
+
if (after === before)
|
|
62
|
+
return true;
|
|
63
|
+
writeAtomic(file, after);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
/** The merge targets git tracks, by one `git ls-files` call; a failed call counts every target as tracked. */
|
|
67
|
+
export function trackedMergeTargets(root, targets) {
|
|
68
|
+
if (targets.length === 0)
|
|
69
|
+
return new Set();
|
|
70
|
+
try {
|
|
71
|
+
const out = execFileSync('git', ['ls-files', '-z', '--', ...targets], { cwd: root, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
72
|
+
return new Set(out.split('\0').filter(Boolean));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return new Set(targets);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// packages/harness/src/mount/files.ts - The mount's filesystem floor: atomic writes, byte reads and hashes, and the
|
|
2
|
+
// removal of folders a prune empties. docs/architecture/systems/harness-records.md speaks for it (Harness mount).
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
|
+
export const sha256Of = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
7
|
+
export function readBytes(file) {
|
|
8
|
+
try {
|
|
9
|
+
return readFileSync(file);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
let sequence = 0;
|
|
16
|
+
/** Temp file then rename, so a concurrent reader sees the old file or the new one, never a torn one. */
|
|
17
|
+
export function writeAtomic(file, bytes) {
|
|
18
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
19
|
+
sequence += 1;
|
|
20
|
+
const temp = join(dirname(file), `.${basename(file)}.mount-${process.pid}-${sequence}`);
|
|
21
|
+
writeFileSync(temp, bytes);
|
|
22
|
+
renameSync(temp, file);
|
|
23
|
+
}
|
|
24
|
+
/** Removes the now-empty folders above a deleted repository path, never a top-level folder or its first child. */
|
|
25
|
+
export function removeEmptyParents(root, relPath) {
|
|
26
|
+
let dir = dirname(relPath);
|
|
27
|
+
while (dir !== '.' && dir.split('/').length > 2) {
|
|
28
|
+
try {
|
|
29
|
+
if (readdirSync(join(root, dir)).length > 0)
|
|
30
|
+
return;
|
|
31
|
+
rmdirSync(join(root, dir));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
dir = dirname(dir);
|
|
37
|
+
}
|
|
38
|
+
}
|