@davesheffer/hunch 1.8.2 → 1.9.2

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 (59) hide show
  1. package/README.md +96 -1
  2. package/dist/cli/index.js +1238 -396
  3. package/dist/constitution/adapters.js +31 -14
  4. package/dist/constitution/behaviorEvaluator.js +20 -7
  5. package/dist/constitution/behaviorProof.js +3 -2
  6. package/dist/constitution/canonical.js +7 -1
  7. package/dist/constitution/card.js +7 -2
  8. package/dist/constitution/compiler.js +71 -1
  9. package/dist/constitution/correctionPolicyMaterializer.js +496 -0
  10. package/dist/constitution/delta.js +3 -2
  11. package/dist/constitution/evaluator.js +29 -3
  12. package/dist/constitution/experiment.js +96 -5
  13. package/dist/constitution/experimentRunner.js +43 -14
  14. package/dist/constitution/g2BehaviorCandidates.js +49 -26
  15. package/dist/constitution/g2BehaviorDependencies.js +203 -14
  16. package/dist/constitution/g2Candidates.js +1 -1
  17. package/dist/constitution/lifecycle.js +17 -0
  18. package/dist/constitution/plan.js +26 -9
  19. package/dist/constitution/replacementFreeGit.js +67 -0
  20. package/dist/constitution/replay.js +6 -0
  21. package/dist/constitution/replayCache.js +1 -1
  22. package/dist/constitution/replayWorker.js +1 -1
  23. package/dist/constitution/repository.js +141 -5
  24. package/dist/constitution/safeCheckout.js +75 -0
  25. package/dist/constitution/schema.js +30 -5
  26. package/dist/constitution/service.js +74 -14
  27. package/dist/constitution/sourceMutation.js +65 -12
  28. package/dist/constitution/staticGraphBaseline.js +44 -0
  29. package/dist/constitution/structural.js +60 -4
  30. package/dist/core/autoreview.js +1 -1
  31. package/dist/core/canonicalOrder.js +6 -0
  32. package/dist/core/conformance.js +68 -27
  33. package/dist/core/docscan.js +2 -1
  34. package/dist/core/escalations.js +11 -0
  35. package/dist/core/io.js +44 -9
  36. package/dist/core/overlaySafety.js +178 -0
  37. package/dist/core/paths.js +13 -2
  38. package/dist/core/safeRepoFile.js +74 -0
  39. package/dist/extractors/comments.js +6 -8
  40. package/dist/extractors/git.js +1631 -82
  41. package/dist/extractors/indexer.js +86 -47
  42. package/dist/extractors/repoSource.js +390 -0
  43. package/dist/integrations/ciAction.js +10 -2
  44. package/dist/integrations/gitignore.js +44 -5
  45. package/dist/integrations/mergeDriver.js +23 -5
  46. package/dist/integrations/sync.js +61 -5
  47. package/dist/integrations/team.js +666 -23
  48. package/dist/mcp/server.js +261 -34
  49. package/dist/store/db.js +57 -7
  50. package/dist/store/hunchStore.js +92 -11
  51. package/dist/store/jsonStore.js +350 -63
  52. package/dist/store/schema.js +27 -11
  53. package/dist/synthesis/provider.js +13 -4
  54. package/dist/synthesis/synthesize.js +56 -19
  55. package/dist/wiki/graph.js +5 -4
  56. package/dist/wiki/wiki.js +16 -10
  57. package/package.json +15 -3
  58. package/tooling/competitive-watch.mjs +108 -0
  59. package/tooling/md1-benchmark.mjs +628 -0
@@ -1,22 +1,207 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
4
- import { basename, join } from "node:path";
3
+ import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readlinkSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
4
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
5
+ import { TextDecoder } from "node:util";
5
6
  import { shortHash } from "../core/ids.js";
7
+ import { compareCodeUnits } from "../core/canonicalOrder.js";
6
8
  import { canonicalHash, canonicalJson } from "./canonical.js";
7
9
  import { replaySafeEnvironment } from "./replay.js";
10
+ import { replacementFreeExactCommit, replacementFreeGitEnvironment } from "./replacementFreeGit.js";
8
11
  const FULL_SHA = /^[a-f0-9]{40}$/;
9
12
  const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
10
- const SNAPSHOT_VERSION = 1;
13
+ // v1 bound only the installed lock and native binaries. Including the version
14
+ // in SnapshotInput makes those caches ineligible and provisions a fresh v2 tree.
15
+ const SNAPSHOT_VERSION = 2;
16
+ const TREE_HASH_VERSION = "hunch-node-modules-tree-v1";
17
+ const HASH_BUFFER_BYTES = 64 * 1024;
18
+ const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
11
19
  function sha256(value) {
12
20
  return `sha256:${createHash("sha256").update(value).digest("hex")}`;
13
21
  }
14
- function gitFile(root, commit, file) {
15
- if (!FULL_SHA.test(commit))
16
- throw new Error(`dependency snapshot commit ${commit} is not a full SHA`);
22
+ function fileSha256(file) {
23
+ const hash = createHash("sha256");
24
+ const buffer = Buffer.allocUnsafe(HASH_BUFFER_BYTES);
25
+ const fd = openSync(file, "r");
26
+ try {
27
+ for (let bytes = readSync(fd, buffer, 0, buffer.length, null); bytes > 0; bytes = readSync(fd, buffer, 0, buffer.length, null)) {
28
+ hash.update(buffer.subarray(0, bytes));
29
+ }
30
+ }
31
+ finally {
32
+ closeSync(fd);
33
+ }
34
+ return `sha256:${hash.digest("hex")}`;
35
+ }
36
+ function hashField(hash, value) {
37
+ const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value;
38
+ const length = Buffer.allocUnsafe(8);
39
+ length.writeBigUInt64BE(BigInt(bytes.length));
40
+ hash.update(length);
41
+ hash.update(bytes);
42
+ }
43
+ function safeEntryName(name) {
44
+ if (!name || name === "." || name === ".." || name.includes("\0") || name.includes("/") || (sep === "\\" && name.includes("\\"))) {
45
+ throw new Error("dependency snapshot contains an unsafe filesystem entry name");
46
+ }
47
+ return name;
48
+ }
49
+ function symlinkTarget(link) {
50
+ const raw = readlinkSync(link, { encoding: "buffer" });
51
+ try {
52
+ return UTF8_DECODER.decode(raw);
53
+ }
54
+ catch {
55
+ throw new Error("dependency snapshot contains a non-UTF-8 symlink target");
56
+ }
57
+ }
58
+ function inside(root, target) {
59
+ return target === root || target.startsWith(`${root}${sep}`);
60
+ }
61
+ function safeInternalSymlink(root, link, target) {
62
+ if (!target || target.includes("\0") || isAbsolute(target)) {
63
+ throw new Error("dependency snapshot contains an absolute or empty symlink target");
64
+ }
65
+ const lexicalRoot = resolve(root);
66
+ const lexicalTarget = resolve(dirname(link), target);
67
+ if (!inside(lexicalRoot, lexicalTarget)) {
68
+ throw new Error("dependency snapshot contains an escaping symlink target");
69
+ }
70
+ const realRoot = realpathSync(root);
71
+ const realTarget = realpathSync(lexicalTarget);
72
+ if (!inside(realRoot, realTarget)) {
73
+ throw new Error("dependency snapshot contains a symlink target outside node_modules");
74
+ }
75
+ const targetStat = statSync(link);
76
+ if (targetStat.isFile())
77
+ return "file";
78
+ if (targetStat.isDirectory())
79
+ return "dir";
80
+ throw new Error("dependency snapshot symlink resolves to a special filesystem entry");
81
+ }
82
+ function treeEntries(dir) {
83
+ return readdirSync(dir, { encoding: "buffer" })
84
+ .sort((left, right) => Buffer.compare(left, right))
85
+ .map((raw) => {
86
+ let name;
87
+ try {
88
+ name = UTF8_DECODER.decode(raw);
89
+ }
90
+ catch {
91
+ throw new Error("dependency snapshot contains a non-UTF-8 filesystem entry name");
92
+ }
93
+ return safeEntryName(name);
94
+ });
95
+ }
96
+ function treeRecord(hash, relative, kind, mode, payload = "") {
97
+ hashField(hash, relative);
98
+ hashField(hash, kind);
99
+ hashField(hash, (mode & 0o7777).toString(8).padStart(4, "0"));
100
+ hashField(hash, payload);
101
+ }
102
+ /** Hash every directory, regular file, executable bit, and internal symlink in
103
+ * a dependency tree. Traversal uses raw UTF-8 byte ordering rather than the
104
+ * host locale, and rejects filesystem shapes that cannot be copied safely. */
105
+ export function dependencySnapshotTreeHash(nodeModules) {
106
+ const rootStat = lstatSync(nodeModules);
107
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
108
+ throw new Error("dependency snapshot node_modules root is not a real directory");
109
+ }
110
+ const hash = createHash("sha256");
111
+ hashField(hash, TREE_HASH_VERSION);
112
+ treeRecord(hash, "", "directory", rootStat.mode);
113
+ const walk = (dir, relative) => {
114
+ for (const name of treeEntries(dir)) {
115
+ const absolute = join(dir, name);
116
+ const next = relative ? `${relative}/${name}` : name;
117
+ const stat = lstatSync(absolute);
118
+ if (stat.isSymbolicLink()) {
119
+ const target = symlinkTarget(absolute);
120
+ safeInternalSymlink(nodeModules, absolute, target);
121
+ treeRecord(hash, next, "symlink", stat.mode, target);
122
+ }
123
+ else if (stat.isDirectory()) {
124
+ treeRecord(hash, next, "directory", stat.mode);
125
+ walk(absolute, next);
126
+ }
127
+ else if (stat.isFile()) {
128
+ treeRecord(hash, next, "file", stat.mode, fileSha256(absolute));
129
+ }
130
+ else {
131
+ throw new Error(`dependency snapshot contains special filesystem entry ${next}`);
132
+ }
133
+ }
134
+ };
135
+ walk(nodeModules, "");
136
+ return `sha256:${hash.digest("hex")}`;
137
+ }
138
+ function copyDependencyTree(source, destination) {
139
+ const copyDirectory = (sourceDir, destinationDir) => {
140
+ const sourceStat = lstatSync(sourceDir);
141
+ if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
142
+ throw new Error("dependency snapshot copy source is not a real directory");
143
+ }
144
+ mkdirSync(destinationDir, { mode: sourceStat.mode & 0o7777 });
145
+ for (const name of treeEntries(sourceDir)) {
146
+ const sourceEntry = join(sourceDir, name);
147
+ const destinationEntry = join(destinationDir, name);
148
+ const stat = lstatSync(sourceEntry);
149
+ if (stat.isSymbolicLink()) {
150
+ const target = symlinkTarget(sourceEntry);
151
+ const targetType = safeInternalSymlink(source, sourceEntry, target);
152
+ symlinkSync(target, destinationEntry, process.platform === "win32" ? targetType : undefined);
153
+ }
154
+ else if (stat.isDirectory()) {
155
+ copyDirectory(sourceEntry, destinationEntry);
156
+ }
157
+ else if (stat.isFile()) {
158
+ copyFileSync(sourceEntry, destinationEntry, fsConstants.COPYFILE_FICLONE);
159
+ chmodSync(destinationEntry, stat.mode & 0o7777);
160
+ }
161
+ else {
162
+ throw new Error("dependency snapshot contains a special filesystem entry");
163
+ }
164
+ }
165
+ chmodSync(destinationDir, sourceStat.mode & 0o7777);
166
+ };
167
+ copyDirectory(source, destination);
168
+ }
169
+ /** Create one independently writable dependency tree. COPYFILE_FICLONE is a
170
+ * copy-on-write optimization where supported, never a shared hardlink; the
171
+ * byte-copy fallback has the same isolation. Pre/post hashes bind the copy. */
172
+ export function materializeDependencyTree(source, destination, opts = {}) {
173
+ if (existsSync(destination))
174
+ throw new Error("dependency snapshot destination already exists");
175
+ const sourceHash = dependencySnapshotTreeHash(source);
176
+ if (opts.expectedHash && sourceHash !== opts.expectedHash) {
177
+ throw new Error("dependency tree hash mismatch before materialization");
178
+ }
179
+ try {
180
+ copyDependencyTree(source, destination);
181
+ if (dependencySnapshotTreeHash(destination) !== sourceHash) {
182
+ throw new Error("dependency tree hash mismatch after materialization");
183
+ }
184
+ return sourceHash;
185
+ }
186
+ catch (error) {
187
+ rmSync(destination, { recursive: true, force: true });
188
+ throw error;
189
+ }
190
+ }
191
+ /** Materialize a private writable dependency tree for one disposable run. */
192
+ export function materializeDependencySnapshot(dependency, destination) {
193
+ materializeDependencyTree(dependency.nodeModules, destination, {
194
+ expectedHash: dependency.snapshot.node_modules_hash,
195
+ });
196
+ }
197
+ function gitFile(root, commit, file, env) {
198
+ if (!FULL_SHA.test(commit) || replacementFreeExactCommit(root, commit) !== commit) {
199
+ throw new Error(`dependency snapshot commit ${commit} is not one exact commit`);
200
+ }
17
201
  try {
18
202
  return execFileSync("git", ["-C", root, "show", `${commit}:${file}`], {
19
203
  encoding: "utf8",
204
+ env: replacementFreeGitEnvironment(env),
20
205
  maxBuffer: 20 * 1024 * 1024,
21
206
  stdio: ["ignore", "pipe", "ignore"],
22
207
  });
@@ -62,7 +247,7 @@ function lockedPackageNames(lock) {
62
247
  return names;
63
248
  }
64
249
  function normalizeAllowlist(values, lock) {
65
- const result = [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
250
+ const result = [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort(compareCodeUnits);
66
251
  const locked = lockedPackageNames(lock);
67
252
  for (const name of result) {
68
253
  if (!PACKAGE_NAME.test(name))
@@ -104,8 +289,8 @@ function runtimeIdentity(env) {
104
289
  return { node: process.version, npm: result.stdout.trim(), platform: process.platform, arch: process.arch };
105
290
  }
106
291
  function snapshotInput(root, commit, allowInstallScripts, env) {
107
- const packageJson = gitFile(root, commit, "package.json");
108
- const packageLock = gitFile(root, commit, "package-lock.json");
292
+ const packageJson = gitFile(root, commit, "package.json", env);
293
+ const packageLock = gitFile(root, commit, "package-lock.json", env);
109
294
  const pkg = parseObject(packageJson, "package.json");
110
295
  const lock = parseObject(packageLock, "package-lock.json");
111
296
  if (lock.lockfileVersion !== 2 && lock.lockfileVersion !== 3) {
@@ -139,7 +324,7 @@ function snapshotInput(root, commit, allowInstallScripts, env) {
139
324
  function nativeInventory(nodeModules) {
140
325
  const files = [];
141
326
  const walk = (dir, relative) => {
142
- for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
327
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => compareCodeUnits(a.name, b.name))) {
143
328
  const absolute = join(dir, entry.name);
144
329
  const next = relative ? `${relative}/${entry.name}` : entry.name;
145
330
  if (entry.isSymbolicLink())
@@ -171,6 +356,8 @@ function readSnapshot(dir) {
171
356
  const installedLock = join(nodeModules, ".package-lock.json");
172
357
  if (!existsSync(nodeModules) || !lstatSync(nodeModules).isDirectory())
173
358
  return null;
359
+ if (dependencySnapshotTreeHash(nodeModules) !== value.node_modules_hash)
360
+ return null;
174
361
  const installedLockHash = existsSync(installedLock) ? sha256(readFileSync(installedLock)) : sha256("");
175
362
  if (installedLockHash !== value.installed_lock_hash)
176
363
  return null;
@@ -232,6 +419,7 @@ function buildSnapshot(base, input, env, timeoutMs) {
232
419
  const nodeModules = join(work, "node_modules");
233
420
  const installedLock = join(nodeModules, ".package-lock.json");
234
421
  mkdirSync(nodeModules, { recursive: true });
422
+ const nodeModulesHash = dependencySnapshotTreeHash(nodeModules);
235
423
  const body = {
236
424
  input_hash: input.inputHash,
237
425
  package_json_hash: input.packageJsonHash,
@@ -242,6 +430,7 @@ function buildSnapshot(base, input, env, timeoutMs) {
242
430
  allow_install_scripts: input.allowInstallScripts,
243
431
  installed_lock_hash: existsSync(installedLock) ? sha256(readFileSync(installedLock)) : sha256(""),
244
432
  native_binaries: nativeInventory(nodeModules),
433
+ node_modules_hash: nodeModulesHash,
245
434
  format_version: SNAPSHOT_VERSION,
246
435
  data_class: "private",
247
436
  authority: "none",
@@ -298,8 +487,8 @@ export function dependencySnapshotForCommit(root, commit, allowedIds) {
298
487
  let packageJson;
299
488
  let packageLock;
300
489
  try {
301
- packageJson = gitFile(root, commit, "package.json");
302
- packageLock = gitFile(root, commit, "package-lock.json");
490
+ packageJson = gitFile(root, commit, "package.json", env);
491
+ packageLock = gitFile(root, commit, "package-lock.json", env);
303
492
  }
304
493
  catch {
305
494
  return null;
@@ -343,7 +532,7 @@ export function provisionG2BehaviorDependencySnapshotsForCommits(root, commits,
343
532
  });
344
533
  return {
345
534
  snapshots: [...new Map([...byInput.values()].map((snapshot) => [snapshot.id, snapshot])).values()]
346
- .sort((left, right) => left.id.localeCompare(right.id)),
535
+ .sort((left, right) => compareCodeUnits(left.id, right.id)),
347
536
  commits: mapped,
348
537
  };
349
538
  }
@@ -367,7 +556,7 @@ export function provisionG2BehaviorDependencySnapshots(root, report, candidate,
367
556
  known_bad: { commit: candidate.proposed_corpus.known_bad.ref, dependency_snapshot_id: bad.dependency_snapshot_id },
368
557
  known_good: { commit: candidate.proposed_corpus.known_good.ref, dependency_snapshot_id: good.dependency_snapshot_id },
369
558
  },
370
- allow_install_scripts: [...new Set(allowInstallScripts.map((value) => value.trim()).filter(Boolean))].sort(),
559
+ allow_install_scripts: [...new Set(allowInstallScripts.map((value) => value.trim()).filter(Boolean))].sort(compareCodeUnits),
371
560
  data_class: "private",
372
561
  authority: "none",
373
562
  effects: "cache_only",
@@ -77,7 +77,7 @@ export function buildG2CandidateReview(store, root, opts = {}, resolutions = [])
77
77
  const graphStore = new HunchStore(hunchPathsForDir(scratchRoot));
78
78
  try {
79
79
  graphStore.json.ensureDirs();
80
- indexRepo(graphStore, root, { churn: false });
80
+ indexRepo(graphStore, root, { churn: false, requireComplete: true });
81
81
  return buildFromIndexedGraph(store, graphStore, root, opts, resolutions);
82
82
  }
83
83
  finally {
@@ -24,11 +24,25 @@ export function blockingEvidenceError(proof, dispositions = []) {
24
24
  return assessHistoryDispositions(proof, dispositions).blocking_error;
25
25
  }
26
26
  const proofRank = { P0: 0, P1: 1, P2: 2, P3: 3, P4: 4, P5: 5 };
27
+ /** Activation is a runtime property, not merely an approval-time check. The
28
+ * audit fallback keeps correction policies compiled before `origin` was added
29
+ * fail-closed after they are loaded by the current schema. */
30
+ export function activationGateError(policy) {
31
+ const isMd1Correction = policy.origin === "correction_md1a"
32
+ || policy.audit.some((event) => event.action === "compiled" && event.actor === "hunch:correction-policy-materializer");
33
+ if (isMd1Correction && policy.activation_gate?.status !== "blocked") {
34
+ return "MD-1a correction policy is missing its required source-currentness activation gate";
35
+ }
36
+ return policy.activation_gate?.status === "blocked" ? policy.activation_gate.reason : null;
37
+ }
27
38
  /** Rechecked on every blocking evaluation; a hand-edited lifecycle flag without
28
39
  * a current P3 proof is a configuration error, never authority. */
29
40
  export function blockingProofError(policy, proof, dispositions = [], composition = [], currentBehaviorAttestations = []) {
30
41
  if (policy.state !== "active_blocking")
31
42
  return null;
43
+ const activationError = activationGateError(policy);
44
+ if (activationError)
45
+ return activationError;
32
46
  if (policy.authority?.kind !== "human")
33
47
  return "active blocking policy has no human authority event";
34
48
  const behaviorAttestationError = executableBehaviorAttestationError(policy, currentBehaviorAttestations);
@@ -95,6 +109,9 @@ export function proposeProvedPolicy(policy, proof, at, composition = [], current
95
109
  };
96
110
  }
97
111
  export function approvePolicy(policy, proof, mode, actor, at, dispositions = [], composition = [], currentBehaviorAttestations = []) {
112
+ const activationError = activationGateError(policy);
113
+ if (activationError)
114
+ throw new Error(`policy ${policy.id} cannot activate: ${activationError}`);
98
115
  requireHuman(actor);
99
116
  const behaviorAttestationError = executableBehaviorAttestationError(policy, currentBehaviorAttestations);
100
117
  if (behaviorAttestationError)
@@ -1,10 +1,12 @@
1
- import { basename } from "node:path";
2
1
  import { shortHash } from "../core/ids.js";
3
- import { firstCommitForFile, headSha, revExists, revParse } from "../extractors/git.js";
2
+ import { stableRepositoryName } from "../extractors/git.js";
4
3
  import { canonicalHash, policySemanticHash } from "./canonical.js";
5
4
  import { policyCompositionBinding, policyProofHash } from "./composition.js";
6
5
  import { graphSnapshot, mutationOperatorForPolicy, selectedPolicyForComposition } from "./evaluator.js";
7
6
  import { createExecutableBehaviorProofPlan } from "./behaviorProof.js";
7
+ import { canonicalStaticGraphBaseline, isAncestorOrSame } from "./staticGraphBaseline.js";
8
+ import { replacementFreeExactCommit, replacementFreeFirstCommitForFile } from "./replacementFreeGit.js";
9
+ export { canonicalStaticGraphBaseline } from "./staticGraphBaseline.js";
8
10
  import { POLICY_EVALUATOR, MUTATION_ENGINE, ProofPlanSchema, } from "./schema.js";
9
11
  function clamp(value, fallback, min, max) {
10
12
  if (value == null || !Number.isFinite(value))
@@ -46,19 +48,26 @@ export function createProofPlan(store, root, repository, policy, opts = {}) {
46
48
  throw new Error("executable-behavior policies cannot have exception composition");
47
49
  return createExecutableBehaviorProofPlan(root, repository, policy, { now: opts.now, privateOnly: true });
48
50
  }
49
- const head = headSha(root);
50
- if (!head)
51
+ const repositoryHead = replacementFreeExactCommit(root, "HEAD");
52
+ if (!repositoryHead)
51
53
  throw new Error("proof planning needs a Git repository with a current HEAD");
54
+ const head = canonicalStaticGraphBaseline(root, repositoryHead);
52
55
  const composition = opts.composition ?? [];
53
56
  const parentHash = policySemanticHash(policy);
54
57
  const policyHash = policyProofHash(policy, composition);
55
58
  const compositionBinding = policyCompositionBinding(policy, composition);
56
59
  const corpus = repository.getCorpus(policy.id, opts);
60
+ // Proof plans are shared memory. A clone-local directory basename would mint
61
+ // different plan IDs for Architect, Developer, CI, and linked worktrees even
62
+ // when every one of them is looking at the same repository and policy. An
63
+ // existing immutable corpus remains the compatibility authority for artifacts
64
+ // created before stable repository identities were introduced.
65
+ const repositoryName = opts.repositoryName ?? corpus?.repository ?? stableRepositoryName(root);
57
66
  if (corpus) {
58
67
  if (corpus.policy_hash !== parentHash) {
59
68
  throw new Error(`proof corpus ${corpus.id} is stale for policy ${policy.id}; re-import it after the policy semantic change`);
60
69
  }
61
- if (corpus.repository !== basename(root) || corpus.data_class !== policy.data_class) {
70
+ if (corpus.repository !== repositoryName || corpus.data_class !== policy.data_class) {
62
71
  throw new Error(`proof corpus ${corpus.id} does not match repository/data class for policy ${policy.id}`);
63
72
  }
64
73
  }
@@ -73,11 +82,19 @@ export function createProofPlan(store, root, repository, policy, opts = {}) {
73
82
  .filter((ref) => ref.startsWith("dec_"))
74
83
  .map(readDecision)
75
84
  .find((record) => !!record);
76
- const policyCommit = firstCommitForFile(`.hunch/policies/${policy.id}.json`, root);
85
+ const policyCommit = replacementFreeFirstCommitForFile(root, `.hunch/policies/${policy.id}.json`);
77
86
  const sourceRef = sourceEvent?.commit ?? decision?.commit ?? (policyCommit || head);
78
- if (!revExists(sourceRef, root))
87
+ const rawSource = replacementFreeExactCommit(root, sourceRef);
88
+ if (!rawSource)
79
89
  throw new Error(`proof-plan source commit ${sourceRef} does not resolve in this repository`);
80
- const sourceCommit = revParse(sourceRef, root);
90
+ // Canonicalize the source independently of current HEAD. A policy introduced
91
+ // by a Hunch-only publication commit remains anchored to the indexed-code (or
92
+ // merge) boundary immediately before that publication, even after later code
93
+ // commits advance the current baseline.
94
+ const sourceCommit = canonicalStaticGraphBaseline(root, rawSource);
95
+ if (!isAncestorOrSame(root, sourceCommit, head)) {
96
+ throw new Error(`proof-plan source commit ${sourceCommit} is not an ancestor of canonical graph baseline ${head}`);
97
+ }
81
98
  const structural = events.find((event) => event.structural_delta && (event.kind === "bug_fix" || event.kind === "revert" || event.kind === "decision"));
82
99
  const structuralKnownBad = structural?.structural_delta
83
100
  ? [{
@@ -112,7 +129,7 @@ export function createProofPlan(store, root, repository, policy, opts = {}) {
112
129
  const body = {
113
130
  policy_id: policy.id,
114
131
  policy_candidate_hash: policyHash,
115
- repository: basename(root),
132
+ repository: repositoryName,
116
133
  data_class: policy.data_class,
117
134
  source_commit: sourceCommit,
118
135
  valid_from_commit: sourceCommit,
@@ -0,0 +1,67 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { foreignRepoEnv } from "../extractors/git.js";
3
+ /** Constitution proof identity is the repository's real object graph, never a
4
+ * clone-local `refs/replace/*` or legacy graft view. Keep this environment
5
+ * private to proof planning so every traversal and ancestry check agrees with
6
+ * replay, which uses the same Git invariant. */
7
+ export function replacementFreeGitEnvironment(source = process.env) {
8
+ return {
9
+ ...foreignRepoEnv(source),
10
+ GIT_NO_REPLACE_OBJECTS: "1",
11
+ };
12
+ }
13
+ function gitText(root, args, maxBuffer = 64 * 1024 * 1024) {
14
+ return execFileSync("git", ["-C", root, ...args], {
15
+ encoding: "utf8",
16
+ env: replacementFreeGitEnvironment(),
17
+ maxBuffer,
18
+ stdio: ["ignore", "pipe", "ignore"],
19
+ }).trim();
20
+ }
21
+ function gitTextSafe(root, args, maxBuffer) {
22
+ try {
23
+ return gitText(root, args, maxBuffer);
24
+ }
25
+ catch {
26
+ return "";
27
+ }
28
+ }
29
+ /** Resolve one commit-ish through the real object graph. */
30
+ export function replacementFreeExactCommit(root, ref) {
31
+ const oid = gitTextSafe(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).toLowerCase();
32
+ return /^[0-9a-f]{40,64}$/.test(oid) ? oid : null;
33
+ }
34
+ /** Files changed by an exact commit, ignoring local replacement objects. */
35
+ export function replacementFreeCommitFiles(root, commit) {
36
+ const out = gitTextSafe(root, ["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", commit]);
37
+ return out ? out.split("\n").filter(Boolean) : [];
38
+ }
39
+ export function replacementFreeCommitMeta(root, commit) {
40
+ const raw = gitTextSafe(root, ["show", "-s", "--format=%H%x1f%h%x1f%s%x1f%b%x1f%an%x1f%aI", commit]);
41
+ if (!raw)
42
+ return null;
43
+ const [sha = "", shortSha = "", subject = "", body = "", author = "", date = ""] = raw.split("\x1f");
44
+ if (!/^[0-9a-f]{40,64}$/i.test(sha))
45
+ return null;
46
+ return { sha, shortSha, subject, body, author, date, files: replacementFreeCommitFiles(root, sha) };
47
+ }
48
+ /** Exact introducing commit for a Git-native policy record. */
49
+ export function replacementFreeFirstCommitForFile(root, file) {
50
+ const added = gitTextSafe(root, ["log", "--diff-filter=A", "--format=%H", "--", file])
51
+ .split("\n")
52
+ .find(Boolean);
53
+ if (added)
54
+ return added;
55
+ return gitTextSafe(root, ["log", "--reverse", "--format=%H", "--", file])
56
+ .split("\n")
57
+ .find(Boolean) ?? "";
58
+ }
59
+ export function replacementFreeIsAncestorOrSame(root, ancestor, descendant) {
60
+ if (ancestor === descendant)
61
+ return true;
62
+ return spawnSync("git", ["-C", root, "merge-base", "--is-ancestor", ancestor, descendant], {
63
+ env: replacementFreeGitEnvironment(),
64
+ stdio: "ignore",
65
+ }).status === 0;
66
+ }
67
+ //# sourceMappingURL=replacementFreeGit.js.map
@@ -7,6 +7,7 @@ import { canonicalHash, proofEvaluationHash, proofPlanContentHash } from "./cano
7
7
  import { assertCompositionBinding, policyProofHash } from "./composition.js";
8
8
  import { evaluateCompositePolicyOnSnapshot, evaluatePolicyOnSnapshot } from "./evaluator.js";
9
9
  import { loadReplaySnapshot, putReplaySnapshot } from "./replayCache.js";
10
+ import { hasUnsafeCheckoutAttributes } from "./safeCheckout.js";
10
11
  import { POLICY_EVALUATOR, ProofPlanSchema, ReplayReceiptSchema, } from "./schema.js";
11
12
  const ZERO_SHA = "0".repeat(40);
12
13
  export const DEFAULT_REPLAY_WORKERS = 4;
@@ -26,6 +27,7 @@ export function replaySafeEnvironment(home, gitConfig) {
26
27
  HOME: home,
27
28
  GIT_CONFIG_GLOBAL: gitConfig,
28
29
  GIT_CONFIG_NOSYSTEM: "1",
30
+ GIT_NO_REPLACE_OBJECTS: "1",
29
31
  GIT_TERMINAL_PROMPT: "0",
30
32
  GIT_LFS_SKIP_SMUDGE: "1",
31
33
  HUNCH_PRIVATE_DIR: "",
@@ -204,6 +206,10 @@ export function replayProofPlan(root, policy, inputPlan, opts = {}) {
204
206
  outcomes.set(commit, { commit, error_code: "timeout" });
205
207
  continue;
206
208
  }
209
+ if (hasUnsafeCheckoutAttributes(root, commit, env, { allowDisabledLfs: true })) {
210
+ outcomes.set(commit, { commit, error_code: "unsafe-checkout-attributes" });
211
+ continue;
212
+ }
207
213
  const cached = loadReplaySnapshot(root, commit, plan.data_class);
208
214
  if (cached.status === "hit" && cached.snapshot) {
209
215
  cacheStats.hits++;
@@ -6,7 +6,7 @@ import { writeFileAtomic } from "../core/io.js";
6
6
  import { canonicalHash } from "./canonical.js";
7
7
  import { graphSnapshotFromRecords } from "./evaluator.js";
8
8
  import { DataClassSchema, POLICY_EVALUATOR } from "./schema.js";
9
- export const REPLAY_CACHE_ENGINE = { name: "hunch-tsjs-static-index", version: "3" };
9
+ export const REPLAY_CACHE_ENGINE = { name: "hunch-tsjs-static-index", version: "4" };
10
10
  const ReplayGraphCacheSchema = z.object({
11
11
  version: z.literal(1),
12
12
  engine: z.object({ name: z.string().min(1), version: z.string().min(1) }),
@@ -13,7 +13,7 @@ let message;
13
13
  try {
14
14
  store = new HunchStore(hunchPathsForDir(input.graph));
15
15
  store.json.ensureDirs();
16
- indexRepo(store, input.checkout, { churn: false });
16
+ indexRepo(store, input.checkout, { churn: false, requireComplete: true });
17
17
  message = {
18
18
  commit: input.commit,
19
19
  snapshot: graphSnapshot(store, input.root, { publicOnly: true, head: input.commit }),