@nurix/nustack 0.23.11 → 0.23.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/commands/check.js +73 -0
  2. package/dist/commands/deploy.js +2 -1
  3. package/dist/commands/doctor.js +2 -1
  4. package/dist/commands/harness.js +127 -48
  5. package/dist/commands/init.js +103 -3
  6. package/dist/commands/login.js +9 -2
  7. package/dist/commands/mcp.js +7 -0
  8. package/dist/commands/project-sync.js +2 -1
  9. package/dist/commands/records.js +10 -0
  10. package/dist/commands/review.js +3 -2
  11. package/dist/commands/sessions.js +4 -1
  12. package/dist/commands/sync.js +2 -1
  13. package/dist/index.js +4 -0
  14. package/dist/lib/agent/claudeHooks.js +4 -1
  15. package/dist/lib/auth_grant.js +15 -3
  16. package/dist/lib/auth_recovery.js +10 -3
  17. package/dist/lib/etna/manager.js +24 -6
  18. package/dist/lib/etna/profile.js +6 -0
  19. package/dist/lib/etna/reconcile.js +6 -0
  20. package/dist/lib/harness/codes.js +24 -0
  21. package/dist/lib/harness/hookCheck.js +325 -0
  22. package/dist/lib/harness/hookEntry.js +27 -0
  23. package/dist/lib/harness/legacyMount.js +231 -0
  24. package/dist/lib/harness/mount.js +199 -131
  25. package/dist/lib/harness/storeWaivers.js +43 -0
  26. package/dist/lib/harness/vendor.js +29 -0
  27. package/dist/lib/harness/versionsClient.js +54 -0
  28. package/dist/lib/local_store/database.js +18 -0
  29. package/dist/lib/nustack_client.js +35 -2
  30. package/dist/lib/onboarding/attach_existing_git.js +14 -2
  31. package/dist/lib/onboarding/binding.js +46 -2
  32. package/dist/lib/onboarding/commit_trailer_hook.js +66 -0
  33. package/dist/lib/onboarding/manifest_writer.js +19 -3
  34. package/dist/lib/onboarding/session_hook.js +76 -0
  35. package/dist/lib/origin_guard.js +43 -0
  36. package/dist/lib/project_contract.js +104 -0
  37. package/dist/lib/records/door.js +26 -19
  38. package/dist/lib/records/drain.js +10 -4
  39. package/dist/lib/records/refusalLog.js +30 -1
  40. package/dist/lib/session-engine/supervisor.js +6 -18
  41. package/dist/lib/sessions/client.js +0 -12
  42. package/dist/program.js +14 -3
  43. package/dist/vendor/harness/assemble/runtimes.js +32 -0
  44. package/dist/vendor/harness/assemble/settingsMerge.js +136 -0
  45. package/dist/vendor/harness/digest/index.js +38 -0
  46. package/dist/vendor/harness/lib/findings.js +32 -0
  47. package/dist/vendor/harness/lib/frontmatter.js +79 -0
  48. package/dist/vendor/harness/lib/paths.js +31 -0
  49. package/dist/vendor/harness/mount/cache.js +65 -0
  50. package/dist/vendor/harness/mount/exclude.js +77 -0
  51. package/dist/vendor/harness/mount/files.js +38 -0
  52. package/dist/vendor/harness/mount/index.js +352 -0
  53. package/dist/vendor/harness/rules/changeRules.js +162 -0
  54. package/dist/vendor/harness/rules/context.js +38 -0
  55. package/dist/vendor/harness/rules/docsRoot.js +34 -0
  56. package/dist/vendor/harness/rules/index.js +109 -0
  57. package/dist/vendor/harness/rules/node.js +144 -0
  58. package/dist/vendor/harness/rules/outputGate.js +28 -0
  59. package/dist/vendor/harness/rules/ownership.js +178 -0
  60. package/dist/vendor/harness/rules/packageTable.js +113 -0
  61. package/dist/vendor/harness/rules/plans.js +96 -0
  62. package/dist/vendor/harness/rules/sessionWalls.js +31 -0
  63. package/package.json +5 -5
@@ -1,8 +1,7 @@
1
- import { existsSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
- import path from "node:path";
4
- import { resolveNuStackUrl } from "../nustack_client.js";
1
+ import { assertCredentialOrigin, resolveNuStackOrigin } from "../nustack_client.js";
5
2
  import { resolveProjectId } from "../project_context.js";
3
+ import { readProjectContract } from "../project_contract.js";
4
+ import { originOf } from "../origin_guard.js";
6
5
  export const RECORD_KINDS = ["obligation", "gate_event", "changelog", "memory", "backlog", "proposal", "triage"];
7
6
  export const LEGACY_KIND_TRANSLATION = { "backlog.remove": "backlog", bookkeeping: "obligation" };
8
7
  export const RECORDS_BATCH_CAP = 200;
@@ -23,33 +22,41 @@ export function translatedKindOf(kind) {
23
22
  return isLegacyKind(kind) ? LEGACY_KIND_TRANSLATION[kind] : kind;
24
23
  }
25
24
  export async function readMountDoor(root) {
26
- const manifest = path.join(root, ".claude", "harness.json");
27
- if (!existsSync(manifest))
28
- return undefined;
29
- try {
30
- const parsed = JSON.parse(await readFile(manifest, "utf8"));
31
- if (!parsed.mount || !("door" in parsed.mount))
32
- return undefined;
33
- const door = parsed.mount.door;
34
- return typeof door === "string" && door.trim() !== "" ? door.trim().replace(/\/+$/, "") : null;
35
- }
36
- catch {
37
- return undefined;
38
- }
25
+ return (await readProjectContract(root)).door;
39
26
  }
40
27
  export async function resolveDoor(root, globals, projectFlag) {
41
28
  const project = await resolveProjectId(root, projectFlag ? { project: projectFlag } : {});
42
29
  if (!project.ok)
43
30
  return { kind: "store", projectId: null, root, reason: "this folder is not bound to a Project" };
44
- const door = await readMountDoor(root);
31
+ const contract = await readProjectContract(root);
32
+ const door = contract.door;
45
33
  if (door === null)
46
34
  return { kind: "store", projectId: project.projectId, root, reason: "the harness manifest configures no door" };
47
- return { kind: "door", projectId: project.projectId, doorUrl: door ?? resolveNuStackUrl(globals), isPlatformDefault: door === undefined, root };
35
+ let origin;
36
+ try {
37
+ origin = resolveNuStackOrigin(globals, root).url;
38
+ }
39
+ catch (error) {
40
+ return { kind: "store", projectId: project.projectId, root, reason: error instanceof Error ? error.message : String(error) };
41
+ }
42
+ if (door !== undefined && contract.doorSource === "contract" && originOf(door) !== originOf(origin)) {
43
+ const reason = `the contract's door ${door} is not on the resolved origin ${origin}; records stay local`;
44
+ process.stderr.write(`nustack: ${reason}\n`);
45
+ return { kind: "store", projectId: project.projectId, root, reason };
46
+ }
47
+ return { kind: "door", projectId: project.projectId, doorUrl: door ?? origin, isPlatformDefault: door === undefined, root };
48
48
  }
49
49
  export function retryDelayMs(attempts) {
50
50
  return Math.min(RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1), RETRY_CEILING_MS);
51
51
  }
52
52
  export async function postRecords(doorUrl, projectId, request, bearer) {
53
+ try {
54
+ await assertCredentialOrigin(doorUrl);
55
+ }
56
+ catch (error) {
57
+ const code = typeof error.code === "string" ? error.code : null;
58
+ return { ok: false, status: 0, code, message: error instanceof Error ? error.message : String(error) };
59
+ }
53
60
  let response;
54
61
  try {
55
62
  response = await fetch(`${doorUrl}/api/v2/projects/${encodeURIComponent(projectId)}/records`, {
@@ -3,6 +3,7 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unl
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { ensureAccessToken } from "../auth_grant.js";
6
+ import { isOriginMismatch } from "../origin_guard.js";
6
7
  import { recordsDrainLockPath } from "../local_store/paths.js";
7
8
  import { resolveNuStackUrl } from "../nustack_client.js";
8
9
  import { RECORDS_BATCH_CAP, postRecords, resolveDoor, retryDelayMs } from "./door.js";
@@ -84,14 +85,19 @@ async function drainPass(store, globals, result, shouldIgnoreBackoff) {
84
85
  if (first.row.credential === "device")
85
86
  bearer = deviceToken;
86
87
  else {
87
- if (personToken === undefined)
88
- personToken = (await ensureAccessToken().catch(() => null))?.accessToken ?? null;
88
+ if (personToken === undefined) {
89
+ personToken = await ensureAccessToken().then((auth) => auth?.accessToken ?? null, (error) => {
90
+ if (isOriginMismatch(error))
91
+ result.originRefusal = error.message;
92
+ return null;
93
+ });
94
+ }
89
95
  bearer = personToken;
90
96
  }
91
97
  for (let start = 0; start < list.length; start += RECORDS_BATCH_CAP) {
92
98
  const batch = list.slice(start, start + RECORDS_BATCH_CAP);
93
99
  if (bearer === null) {
94
- backoffAll(store, batch, "this device is signed out", result);
100
+ backoffAll(store, batch, result.originRefusal ?? "this device is signed out", result);
95
101
  continue;
96
102
  }
97
103
  await sendBatch(store, batch, first.row.kind, bearer, result);
@@ -140,7 +146,7 @@ async function sendBatch(store, batch, kind, bearer, result) {
140
146
  }
141
147
  }
142
148
  export async function drainRecords(store, globals, options = {}) {
143
- const result = { sent: 0, refused: 0, lastError: null };
149
+ const result = { sent: 0, refused: 0, lastError: null, originRefusal: null };
144
150
  pruneSynced(store, Date.now());
145
151
  for (let pass = 0; pass < MAX_PASSES; pass += 1) {
146
152
  options.onPass?.();
@@ -2,7 +2,36 @@ import { randomUUID } from "node:crypto";
2
2
  import { RECORD_SCHEMA_VERSION } from "./door.js";
3
3
  import { idempotencyKeyOf } from "./naturalKey.js";
4
4
  import { writeRecords } from "./recordsTable.js";
5
- export const REFUSAL_GATE_IDS = ["records-validation", "records-unknown-kind", "agent-owned-obligation", "secret-scan"];
5
+ export const REFUSAL_GATE_IDS = [
6
+ "records-validation",
7
+ "records-unknown-kind",
8
+ "agent-owned-obligation",
9
+ "secret-scan",
10
+ "ownership",
11
+ "plan-gate",
12
+ "ledger",
13
+ "frozen-record",
14
+ "changelog",
15
+ "changelog-entry",
16
+ "package-table",
17
+ "protected-file",
18
+ "output-gate",
19
+ "branch-worktree",
20
+ "live-command",
21
+ "commit-claim",
22
+ "foreign-claim",
23
+ "open-denials",
24
+ "exit-stop",
25
+ "queue",
26
+ "completion-integrity",
27
+ "changelog-presence",
28
+ "commit-gate",
29
+ "file-gate",
30
+ "bash-write-screen",
31
+ "harness-checks",
32
+ "hook.rule_unavailable",
33
+ "hook.deadline",
34
+ ];
6
35
  const TEXT_CAP = 20_000;
7
36
  const clip = (text) => (text.length > TEXT_CAP ? `${text.slice(0, TEXT_CAP - 1)}…` : text || "-");
8
37
  export function logRefusals(store, entries, context) {
@@ -1,13 +1,11 @@
1
1
  import { execFile } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
3
- import path from "node:path";
4
2
  import { createInterface } from "node:readline";
5
3
  import { ClaudeSession } from "./claudeAdapter.js";
6
4
  import { FRAME_VERSION, parseInboundFrame, serializeFrame, } from "./frames.js";
7
5
  import { inspectSession, reclaimSession } from "./prune.js";
8
6
  import { sessionRegistryPath, readSessionRegistry, updateSessionRegistry } from "./registry.js";
9
7
  import { createSessionWorktree } from "./worktree.js";
10
- import { mountHarness } from "../harness/mount.js";
8
+ import { readProjectContract } from "../project_contract.js";
11
9
  function defaultProbeClaude(env) {
12
10
  return new Promise((resolve) => {
13
11
  execFile("claude", ["--version"], { env, timeout: 5_000 }, (error, stdout) => {
@@ -41,14 +39,7 @@ export class SessionSupervisor {
41
39
  this.emit({ v: FRAME_VERSION, type: "session.error", sessionId, code, message });
42
40
  }
43
41
  async boundProjectOf(projectPath) {
44
- try {
45
- const parsed = JSON.parse(await readFile(path.join(projectPath, ".nustack", "workspace.json"), "utf8"));
46
- const id = parsed?.workspaceId;
47
- return typeof id === "string" && id.trim() ? id.trim() : null;
48
- }
49
- catch {
50
- return null;
51
- }
42
+ return (await readProjectContract(projectPath)).projectId;
52
43
  }
53
44
  async moveSessionRow(record, to) {
54
45
  const client = this.options.sessions;
@@ -133,17 +124,14 @@ export class SessionSupervisor {
133
124
  }
134
125
  }
135
126
  async mountInto(sessionId, root, projectId) {
136
- const lookup = this.options.sessions?.harnessManifestUrl;
137
- if (!lookup)
127
+ const mount = this.options.mountHarness;
128
+ if (!mount)
138
129
  return;
130
+ const log = this.options.log ?? ((line) => process.stderr.write(`${line}\n`));
139
131
  try {
140
- const manifestUrl = await lookup.call(this.options.sessions, projectId);
141
- if (!manifestUrl)
142
- return;
143
- await (this.options.mountHarness ?? mountHarness)(root, manifestUrl);
132
+ await mount(root, { projectId, writeContract: "never", runtime: "claude-code", log: () => { } });
144
133
  }
145
134
  catch (error) {
146
- const log = this.options.log ?? ((line) => process.stderr.write(`${line}\n`));
147
135
  log(`nustack: harness mount skipped for session ${sessionId} - ${error instanceof Error ? error.message : String(error)}`);
148
136
  }
149
137
  }
@@ -34,18 +34,6 @@ export function makeSessionsClient(baseUrl, accessToken) {
34
34
  async setVendorSessionId(projectId, sessionId, vendorSessionId) {
35
35
  return this.transitionSession(projectId, sessionId, "running", { vendorSessionId });
36
36
  },
37
- async harnessManifestUrl(projectId) {
38
- const safeId = assertSafeOpaqueId("The Project id", projectId);
39
- const response = await globalThis.fetch(`${baseUrl}/api/v2/projects/${encodeURIComponent(safeId)}`, {
40
- headers,
41
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
42
- });
43
- const payload = (await response.json().catch(() => null));
44
- if (!response.ok)
45
- throw new Error(`project summary fetch failed: HTTP ${response.status}`);
46
- const url = payload?.data?.harness?.manifestUrl;
47
- return typeof url === "string" && url ? url : null;
48
- },
49
37
  };
50
38
  }
51
39
  export function sessionsClientFromEnv(env = process.env) {
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";
@@ -68,7 +69,9 @@ program
68
69
  .command("init")
69
70
  .description("Onboard this folder: sign in, then bind it to a Project (the onboarding router)")
70
71
  .option("--organization <id>", "the Organization to create or select the Project in")
72
+ .option("--project <id>", "the Project to bind (with --yes and --organization: the unattended path)")
71
73
  .option("--yes", "pre-confirm the populated-folder onboarding guard")
74
+ .option("--json", "emit the versioned machine envelope (unattended path only)")
72
75
  .action(async (options) => runInit({ ...program.opts(), ...options }));
73
76
  program
74
77
  .command("add [service]")
@@ -244,7 +247,7 @@ registerProjectSyncCommands(project, () => program.opts());
244
247
  registerReviewCommands(program.command("review").description("Read and mirror NuStack Review items"), () => program.opts());
245
248
  registerRecordsCommands(program.command("records").description("Emit, send and list harness records"), () => program.opts());
246
249
  registerRecordAliases(program, () => program.opts());
247
- const LOCAL_ONLY_COMMANDS = ["census"];
250
+ const LOCAL_ONLY_COMMANDS = ["census", "check"];
248
251
  const UNDRAINED_GROUPS = ["records", "gate", ...RECORD_ALIAS_GROUPS];
249
252
  program.hook("preAction", async (_program, action) => {
250
253
  if (program.opts().nonInteractive)
@@ -257,6 +260,13 @@ program.hook("preAction", async (_program, action) => {
257
260
  if (action.name() !== "telemetry" && isPartCheckStale())
258
261
  startTelemetryReconcile({ nustackUrl: program.opts().nustackUrl });
259
262
  });
263
+ program
264
+ .command("check [rule]")
265
+ .description("Run one harness rule over this checkout; exits 0 pass, 1 block or flag, 2 when it cannot run")
266
+ .option("--json", "emit the versioned machine envelope")
267
+ .option("--hook <event>", "payload mode for the seed's thin hooks: pre-tool-use or stop (reads the hook payload on stdin)")
268
+ .option("--protection", "with --hook: the Protection wall's entry (block-rung Protection rows only)")
269
+ .action(async (rule, options) => runCheck(rule, options));
260
270
  const docs = program.command("docs").description("Work with a Project's Documents");
261
271
  docs
262
272
  .command("list")
@@ -321,12 +331,13 @@ harness
321
331
  .command("mount [path]")
322
332
  .description("Mount the resolved harness version into a checkout (default: the current repository)")
323
333
  .option("--project <id>", "the Project to act as (defaults to the bound repository)")
324
- .option("--manifest <url>", "mount this manifest instead of the one the Project resolves")
334
+ .option("--runtime <runtime>", "the runtime whose half of the version is mounted (claude-code, codex, opencode, copilot-cli)")
335
+ .option("--write-contract <mode>", "whether the mount may write CLAUDE.md / AGENTS.md (never, when-unmodified)")
325
336
  .option("--json", "emit the versioned machine envelope")
326
337
  .action(async (target, options) => runHarnessMount(target, options, program.opts()));
327
338
  harness
328
339
  .command("status")
329
- .description("Print the mounted harness against the cloud pin; exits 1 when they differ")
340
+ .description("Print the mounted harness against the expected version; exits 1 when they differ, 2 when unreachable")
330
341
  .option("--project <id>", "the Project to act as (defaults to the bound repository)")
331
342
  .option("--json", "emit the versioned machine envelope")
332
343
  .action(async (options) => runHarnessStatus(options, program.opts()));
@@ -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);