@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.
- package/README.md +96 -1
- package/dist/cli/index.js +1238 -396
- package/dist/constitution/adapters.js +31 -14
- package/dist/constitution/behaviorEvaluator.js +20 -7
- package/dist/constitution/behaviorProof.js +3 -2
- package/dist/constitution/canonical.js +7 -1
- package/dist/constitution/card.js +7 -2
- package/dist/constitution/compiler.js +71 -1
- package/dist/constitution/correctionPolicyMaterializer.js +496 -0
- package/dist/constitution/delta.js +3 -2
- package/dist/constitution/evaluator.js +29 -3
- package/dist/constitution/experiment.js +96 -5
- package/dist/constitution/experimentRunner.js +43 -14
- package/dist/constitution/g2BehaviorCandidates.js +49 -26
- package/dist/constitution/g2BehaviorDependencies.js +203 -14
- package/dist/constitution/g2Candidates.js +1 -1
- package/dist/constitution/lifecycle.js +17 -0
- package/dist/constitution/plan.js +26 -9
- package/dist/constitution/replacementFreeGit.js +67 -0
- package/dist/constitution/replay.js +6 -0
- package/dist/constitution/replayCache.js +1 -1
- package/dist/constitution/replayWorker.js +1 -1
- package/dist/constitution/repository.js +141 -5
- package/dist/constitution/safeCheckout.js +75 -0
- package/dist/constitution/schema.js +30 -5
- package/dist/constitution/service.js +74 -14
- package/dist/constitution/sourceMutation.js +65 -12
- package/dist/constitution/staticGraphBaseline.js +44 -0
- package/dist/constitution/structural.js +60 -4
- package/dist/core/autoreview.js +1 -1
- package/dist/core/canonicalOrder.js +6 -0
- package/dist/core/conformance.js +68 -27
- package/dist/core/docscan.js +2 -1
- package/dist/core/escalations.js +11 -0
- package/dist/core/io.js +44 -9
- package/dist/core/overlaySafety.js +178 -0
- package/dist/core/paths.js +13 -2
- package/dist/core/safeRepoFile.js +74 -0
- package/dist/extractors/comments.js +6 -8
- package/dist/extractors/git.js +1631 -82
- package/dist/extractors/indexer.js +86 -47
- package/dist/extractors/repoSource.js +390 -0
- package/dist/integrations/ciAction.js +10 -2
- package/dist/integrations/gitignore.js +44 -5
- package/dist/integrations/mergeDriver.js +23 -5
- package/dist/integrations/sync.js +61 -5
- package/dist/integrations/team.js +666 -23
- package/dist/mcp/server.js +261 -34
- package/dist/store/db.js +57 -7
- package/dist/store/hunchStore.js +92 -11
- package/dist/store/jsonStore.js +350 -63
- package/dist/store/schema.js +27 -11
- package/dist/synthesis/provider.js +13 -4
- package/dist/synthesis/synthesize.js +56 -19
- package/dist/wiki/graph.js +5 -4
- package/dist/wiki/wiki.js +16 -10
- package/package.json +15 -3
- package/tooling/competitive-watch.mjs +108 -0
- 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,
|
|
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 =
|
|
94
|
+
const path = assertSafeTopLevelConfigFile(root, ".gitignore");
|
|
58
95
|
const block = [mark, ...entries, end].join("\n");
|
|
59
96
|
if (!existsSync(path)) {
|
|
60
|
-
|
|
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
|
-
|
|
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 =
|
|
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)
|