@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
@@ -9,8 +9,9 @@
9
9
  * Idempotent + merge-safe (con_8460b6770f): appends a single marked block and
10
10
  * never rewrites the user's existing entries; re-running is a no-op.
11
11
  */
12
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
13
- import { join } from "node:path";
12
+ import { readFileSync, existsSync, lstatSync, realpathSync } from "node:fs";
13
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
14
+ import { writeFileAtomic } from "../core/io.js";
14
15
  const MARK = "# >>> hunch (derived runtime index — regenerable from .hunch/*.json) >>>";
15
16
  const END = "# <<< hunch <<<";
16
17
  const ENTRIES = [
@@ -50,14 +51,51 @@ const MEM_ENTRIES = [
50
51
  ".hunch/symbols/",
51
52
  ".hunch/edges/",
52
53
  ];
54
+ function pathIsWithin(path, parent) {
55
+ const rel = relative(parent, path);
56
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
57
+ }
58
+ /** Defense in depth for integration files written automatically after a clone.
59
+ * Refuse symlinks, directories/devices, and hard links; require the canonical
60
+ * target to be the expected top-level file inside the canonical repository root. */
61
+ export function assertSafeTopLevelConfigFile(root, name) {
62
+ const lexicalRoot = resolve(root);
63
+ const rootStat = lstatSync(lexicalRoot);
64
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
65
+ throw new Error(`refusing to write integration config through unsafe repository root: ${root}`);
66
+ }
67
+ if (name !== ".gitignore" && name !== ".gitattributes") {
68
+ throw new Error(`refusing unexpected integration config path: ${name}`);
69
+ }
70
+ const canonicalRoot = realpathSync(lexicalRoot);
71
+ const path = join(lexicalRoot, name);
72
+ let stat;
73
+ try {
74
+ stat = lstatSync(path);
75
+ }
76
+ catch (error) {
77
+ if (error.code === "ENOENT")
78
+ return path;
79
+ throw error;
80
+ }
81
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) {
82
+ throw new Error(`refusing to write unsafe integration config: ${path}`);
83
+ }
84
+ const canonicalPath = realpathSync(path);
85
+ if (!pathIsWithin(canonicalPath, canonicalRoot) || canonicalPath !== join(canonicalRoot, name)) {
86
+ throw new Error(`refusing integration config outside repository root: ${path}`);
87
+ }
88
+ return path;
89
+ }
53
90
  /** Idempotent + merge-safe append of one marked block (con_8460b6770f): never
54
91
  * rewrites the user's existing entries, and re-running is a no-op once the block
55
92
  * (or an equivalent hand-written set of the same patterns) is present. */
56
93
  function appendBlock(root, mark, entries, end) {
57
- const path = join(root, ".gitignore");
94
+ const path = assertSafeTopLevelConfigFile(root, ".gitignore");
58
95
  const block = [mark, ...entries, end].join("\n");
59
96
  if (!existsSync(path)) {
60
- writeFileSync(path, block + "\n");
97
+ assertSafeTopLevelConfigFile(root, ".gitignore");
98
+ writeFileAtomic(path, block + "\n");
61
99
  return { path, action: "created" };
62
100
  }
63
101
  const cur = readFileSync(path, "utf8");
@@ -70,7 +108,8 @@ function appendBlock(root, mark, entries, end) {
70
108
  if (entries.every((e) => lines.has(e)))
71
109
  return { path, action: "unchanged" };
72
110
  const sep = cur.endsWith("\n") || cur.length === 0 ? "" : "\n";
73
- writeFileSync(path, `${cur}${sep}${block}\n`);
111
+ assertSafeTopLevelConfigFile(root, ".gitignore");
112
+ writeFileAtomic(path, `${cur}${sep}${block}\n`);
74
113
  return { path, action: "appended" };
75
114
  }
76
115
  export function ensureGitignore(root) {
@@ -6,15 +6,30 @@
6
6
  */
7
7
  import { execFileSync } from "node:child_process";
8
8
  import { readFileSync, existsSync } from "node:fs";
9
- import { join } from "node:path";
10
9
  import { writeFileAtomic } from "../core/io.js";
10
+ import { assertSafeTopLevelConfigFile } from "./gitignore.js";
11
11
  // Route the .hunch JSON records through the structured driver — but NOT the
12
12
  // manifest (an id-less `{schema_version}` object the driver can't merge by id; a
13
13
  // normal text merge with conflict markers is the right behavior for it).
14
14
  const ATTR_LINES = [".hunch/**/*.json merge=hunch", ".hunch/manifest.json merge=text"];
15
+ function targetRepositoryEnv() {
16
+ const env = { ...process.env };
17
+ for (const key of [
18
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT",
19
+ "GIT_OBJECT_DIRECTORY", "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE",
20
+ "GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS", "GIT_REPLACE_REF_BASE", "GIT_PREFIX",
21
+ "GIT_INTERNAL_SUPER_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR",
22
+ ])
23
+ delete env[key];
24
+ for (const key of Object.keys(env)) {
25
+ if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key))
26
+ delete env[key];
27
+ }
28
+ return env;
29
+ }
15
30
  export function installMergeDriver(root, invShell) {
16
31
  // 1. .gitattributes — committed, shared with the team so the routing travels.
17
- const attrPath = join(root, ".gitattributes");
32
+ const attrPath = assertSafeTopLevelConfigFile(root, ".gitattributes");
18
33
  let text = existsSync(attrPath) ? readFileSync(attrPath, "utf8") : "";
19
34
  let attrAction = "present";
20
35
  for (const line of ATTR_LINES) {
@@ -24,14 +39,17 @@ export function installMergeDriver(root, invShell) {
24
39
  attrAction = "written";
25
40
  }
26
41
  }
27
- if (attrAction === "written")
42
+ if (attrAction === "written") {
43
+ assertSafeTopLevelConfigFile(root, ".gitattributes");
28
44
  writeFileAtomic(attrPath, text);
45
+ }
29
46
  // 2. Local git config — the driver definition is per-clone (it references this
30
47
  // machine's node + cli path), so it is NOT committed; teammates re-run init.
31
48
  const driver = `${invShell} merge-driver "%O" "%A" "%B" "%P"`;
49
+ const env = targetRepositoryEnv();
32
50
  try {
33
- execFileSync("git", ["config", "merge.hunch.name", "hunch structured JSON merge"], { cwd: root });
34
- execFileSync("git", ["config", "merge.hunch.driver", driver], { cwd: root });
51
+ execFileSync("git", ["config", "merge.hunch.name", "hunch structured JSON merge"], { cwd: root, env });
52
+ execFileSync("git", ["config", "merge.hunch.driver", driver], { cwd: root, env });
35
53
  }
36
54
  catch {
37
55
  return { action: `${attrAction} .gitattributes — but \`git config\` failed (not a git repo?)` };
@@ -10,11 +10,60 @@
10
10
  import { dirname } from "node:path";
11
11
  import { commitAndPushHunch } from "../extractors/git.js";
12
12
  import { refreshCommittableGrounding } from "./providers.js";
13
+ import { advertisedTeamRemoteContract } from "./team.js";
14
+ const pinnedSharedRoutes = new WeakMap();
15
+ /** Bind one command/server Store instance to the graph epoch that admitted it.
16
+ * Every later flush from that instance reuses the same verifying contract. */
17
+ export function pinSharedRemote(store, remote) {
18
+ pinnedSharedRoutes.set(store, remote);
19
+ }
20
+ /** Return the route admitted for this Store instance. Command paths that flush
21
+ * directly (rather than through flushCapture) must use this accessor too, or a
22
+ * coherent team.json/origin rewrite could switch graphs mid-command. */
23
+ export function sharedRemoteFor(store) {
24
+ if (store.mode !== "shared" || !store.privateDir)
25
+ return undefined;
26
+ return pinnedSharedRoutes.get(store)
27
+ ?? advertisedTeamRemoteContract(store.publicRoot, dirname(store.privateDir));
28
+ }
29
+ /** Flush one exact artifact home. Constitution repositories can explicitly
30
+ * choose public even in unified mode, so this must not infer routing through
31
+ * captureHome(isPrivate). The caller supplies the repository's actual home. */
32
+ export function flushMemoryHome(store, publicHunchDir, home, message, remoteOverride) {
33
+ if (home === "private") {
34
+ if (!store.privateAutoCommit || !store.privateDir)
35
+ return null;
36
+ return commitAndPushHunch(store.privateDir, message, {
37
+ push: true,
38
+ protectedRepoRoot: store.publicRoot,
39
+ remote: remoteOverride ?? sharedRemoteFor(store),
40
+ });
41
+ }
42
+ if (!store.autoCommit)
43
+ return null;
44
+ const grounding = refreshCommittableGrounding(dirname(publicHunchDir), store);
45
+ return commitAndPushHunch(publicHunchDir, message, { push: false, alsoStage: grounding });
46
+ }
47
+ /** One completion flush per touched home. A mixed ingest/bootstrap can write
48
+ * both homes; unchanged homes are cheap no-ops after the memory-only stage
49
+ * check, while each real home becomes durable exactly once. */
50
+ export function flushMemoryHomes(store, publicHunchDir, homes, message, remoteOverride) {
51
+ const results = {};
52
+ for (const home of new Set(homes)) {
53
+ results[home] = flushMemoryHome(store, publicHunchDir, home, message, remoteOverride);
54
+ }
55
+ return results;
56
+ }
13
57
  /** Auto-commit + push the overlay after a private write, when auto-commit is on. No-op
14
58
  * otherwise (manual `hunch private --sync` still works). Never throws. */
15
59
  export function flushPrivate(store, message) {
16
- if (store.privateAutoCommit && store.privateDir)
17
- commitAndPushHunch(store.privateDir, message);
60
+ if (store.privateAutoCommit && store.privateDir) {
61
+ commitAndPushHunch(store.privateDir, message, {
62
+ push: true,
63
+ protectedRepoRoot: store.publicRoot,
64
+ remote: sharedRemoteFor(store),
65
+ });
66
+ }
18
67
  }
19
68
  /** Auto-commit the store a capture landed in. Returns what ACTUALLY happened so callers
20
69
  * never report a commit that was skipped: "pushed" (overlay committed + pushed),
@@ -22,12 +71,19 @@ export function flushPrivate(store, message) {
22
71
  * overlay commit whose merge/push failed retries on the next flush), or null (auto-commit
23
72
  * off, no overlay for a private record, or the commit was skipped — lock held, safety
24
73
  * backstop, nothing staged; the record stays on disk and the next flush sweeps it up). */
25
- export function flushCapture(store, publicHunchDir, isPrivate, message) {
74
+ export function flushCapture(store, publicHunchDir, isPrivate, message,
75
+ /** Long-lived callers can pin the route snapshot that admitted the write. */
76
+ remoteOverride) {
26
77
  // Follow the same routing as HunchStore.captureHome: unified ("shared") mode homes
27
78
  // EVERY capture in the overlay, so the flush must go there too — one source of truth.
28
79
  if (store.captureHome(isPrivate) === "private") {
29
- if (store.privateAutoCommit && store.privateDir)
30
- return commitAndPushHunch(store.privateDir, message);
80
+ if (store.privateAutoCommit && store.privateDir) {
81
+ return commitAndPushHunch(store.privateDir, message, {
82
+ push: true,
83
+ protectedRepoRoot: store.publicRoot,
84
+ remote: remoteOverride ?? sharedRemoteFor(store),
85
+ });
86
+ }
31
87
  return null;
32
88
  }
33
89
  if (!store.autoCommit)