@davesheffer/hunch 1.7.0 → 1.8.0
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 +242 -0
- package/bench/constitution-exp03-v1.json +70 -0
- package/dist/cli/index.js +1630 -107
- package/dist/constitution/adapters.js +487 -0
- package/dist/constitution/behaviorAttestationBinding.js +17 -0
- package/dist/constitution/behaviorEvaluator.js +220 -0
- package/dist/constitution/behaviorProof.js +205 -0
- package/dist/constitution/behaviorWorkspace.js +124 -0
- package/dist/constitution/bootstrap.js +133 -0
- package/dist/constitution/canonical.js +51 -0
- package/dist/constitution/card.js +133 -0
- package/dist/constitution/compiler.js +176 -0
- package/dist/constitution/composition.js +101 -0
- package/dist/constitution/corpus.js +58 -0
- package/dist/constitution/delta.js +154 -0
- package/dist/constitution/disposition.js +141 -0
- package/dist/constitution/evaluator.js +435 -0
- package/dist/constitution/experiment.js +1007 -0
- package/dist/constitution/experimentRunner.js +344 -0
- package/dist/constitution/g2.js +291 -0
- package/dist/constitution/g2BehaviorAttestation.js +209 -0
- package/dist/constitution/g2BehaviorCandidates.js +703 -0
- package/dist/constitution/g2BehaviorDependencies.js +379 -0
- package/dist/constitution/g2BehaviorMaterialization.js +171 -0
- package/dist/constitution/g2BehaviorPolicyMaterializer.js +241 -0
- package/dist/constitution/g2CandidateAttestation.js +179 -0
- package/dist/constitution/g2Candidates.js +195 -0
- package/dist/constitution/g2Drills.js +122 -0
- package/dist/constitution/g3.js +511 -0
- package/dist/constitution/g3Conformance.js +132 -0
- package/dist/constitution/lifecycle.js +224 -0
- package/dist/constitution/mutation.js +262 -0
- package/dist/constitution/nodeTestEvidence.js +47 -0
- package/dist/constitution/plan.js +172 -0
- package/dist/constitution/policyRuntime.js +8 -0
- package/dist/constitution/proof.js +166 -0
- package/dist/constitution/repairPolicies.js +78 -0
- package/dist/constitution/replay.js +361 -0
- package/dist/constitution/replayCache.js +89 -0
- package/dist/constitution/replayWorker.js +34 -0
- package/dist/constitution/repository.js +533 -0
- package/dist/constitution/schema.js +545 -0
- package/dist/constitution/scorecard.js +106 -0
- package/dist/constitution/service.js +1211 -0
- package/dist/constitution/shadow.js +235 -0
- package/dist/constitution/sourceMutation.js +316 -0
- package/dist/constitution/structural.js +601 -0
- package/dist/core/autoreview.js +27 -3
- package/dist/core/dupdetect.js +10 -3
- package/dist/core/escalations.js +65 -0
- package/dist/core/events.js +61 -0
- package/dist/core/externalImports.js +24 -0
- package/dist/core/hookpolicy.js +3 -0
- package/dist/core/memorylog.js +69 -0
- package/dist/core/relativeImports.js +33 -0
- package/dist/core/repair.js +71 -0
- package/dist/core/reviewqueue.js +11 -0
- package/dist/core/stats.js +115 -0
- package/dist/extractors/git.js +120 -0
- package/dist/extractors/indexer.js +39 -38
- package/dist/extractors/nativeTreeSitter.js +108 -0
- package/dist/extractors/parse.js +5 -15
- package/dist/integrations/claudemd.js +8 -1
- package/dist/integrations/gitignore.js +8 -0
- package/dist/integrations/providers.js +32 -10
- package/dist/integrations/sync.js +16 -1
- package/dist/mcp/server.js +317 -1
- package/dist/synthesis/synthesize.js +8 -1
- package/dist/wiki/graph.js +301 -0
- package/dist/wiki/wiki.js +31 -3
- package/package.json +5 -1
package/dist/extractors/git.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { isAbsolute, resolve, join, basename, dirname } from "node:path";
|
|
5
5
|
import { mkdirSync, rmSync, statSync, realpathSync, readFileSync } from "node:fs";
|
|
6
|
+
import { MEMLOG_FORMAT } from "../core/memorylog.js";
|
|
6
7
|
function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
7
8
|
// stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
|
|
8
9
|
return execFileSync("git", args, {
|
|
@@ -18,6 +19,17 @@ function gitSafe(args, cwd, maxBuffer) {
|
|
|
18
19
|
return "";
|
|
19
20
|
}
|
|
20
21
|
}
|
|
22
|
+
function gitRawSafe(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
23
|
+
try {
|
|
24
|
+
return execFileSync("git", args, {
|
|
25
|
+
cwd, encoding: "utf8", maxBuffer,
|
|
26
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
21
33
|
export function isGitRepo(cwd) {
|
|
22
34
|
return gitSafe(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
|
|
23
35
|
}
|
|
@@ -82,6 +94,14 @@ export function commitAndPushHunch(hunchDir, message, opts = {}) {
|
|
|
82
94
|
}
|
|
83
95
|
return null;
|
|
84
96
|
}
|
|
97
|
+
// Grounding docs refreshed by this capture ride the same memory commit, so committed record
|
|
98
|
+
// counts can never go stale (the refresh-counts treadmill: every capture commit bumped the
|
|
99
|
+
// count and re-staled the docs for the next release-gate clean-tree check). Staged AFTER the
|
|
100
|
+
// memory-only backstop on purpose: alsoStage is a code-controlled list of generated grounding
|
|
101
|
+
// docs the caller verified git-clean BEFORE rewriting, so it can neither weaken the
|
|
102
|
+
// bug_overlay_clobber detection above nor sweep user edits.
|
|
103
|
+
for (const file of opts.alsoStage ?? [])
|
|
104
|
+
run(["add", "--", file]);
|
|
85
105
|
// Only sync+push when a memory commit was actually created — never run pull/push against the
|
|
86
106
|
// enclosing repo on an empty stage. Two-way sync: MERGE the remote BEFORE pushing so a push
|
|
87
107
|
// can't be rejected non-fast-forward; the .hunch merge driver resolves same-record conflicts
|
|
@@ -112,6 +132,16 @@ export function commitAndPushHunch(hunchDir, message, opts = {}) {
|
|
|
112
132
|
catch { /* released best-effort */ }
|
|
113
133
|
}
|
|
114
134
|
}
|
|
135
|
+
/** True when `rel` is tracked with no staged or unstaged changes (untracked counts as
|
|
136
|
+
* dirty, so a doc the user never committed is never swept into a memory commit). */
|
|
137
|
+
export function isGitCleanPath(root, rel) {
|
|
138
|
+
try {
|
|
139
|
+
return execFileSync("git", ["-C", root, "status", "--porcelain", "--", rel], { encoding: "utf8" }).trim() === "";
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
115
145
|
/** Is the staged set a clean, MEMORY-ONLY change — only JSON record adds/updates, nothing else?
|
|
116
146
|
* The overlay store is entirely JSON (decisions/, bugs/, …, manifest.json). A real memory sync
|
|
117
147
|
* is purely additive; a DELETION, rename, or any non-.json staged path means hunchDir is NOT a
|
|
@@ -266,6 +296,44 @@ export function commitFiles(sha, cwd) {
|
|
|
266
296
|
const out = gitSafe(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
|
|
267
297
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
268
298
|
}
|
|
299
|
+
/** Raw `git log` over `.hunch/`, paired with parseMemoryLog — the memory-move
|
|
300
|
+
* timeline (each commit that changed the graph). Newest first; empty on any error
|
|
301
|
+
* (no repo / no history), so the caller degrades to an empty timeline. */
|
|
302
|
+
export function gitMemoryLog(root, limit = 200) {
|
|
303
|
+
return gitSafe(["log", `--max-count=${limit}`, "--no-color", `--format=${MEMLOG_FORMAT}`, "--name-status", "--", ".hunch/"], root);
|
|
304
|
+
}
|
|
305
|
+
/** The diff of a single commit restricted to `.hunch/` — what one memory move
|
|
306
|
+
* actually changed, for the click-through popup. Empty on error. */
|
|
307
|
+
export function memoryMoveDiff(sha, root) {
|
|
308
|
+
return gitSafe(["show", "--no-color", "--format=%H%n%an%n%cI%n%s%n", sha, "--", ".hunch/"], root);
|
|
309
|
+
}
|
|
310
|
+
/** Push the current branch to its remote (the "approve-to-push" step — public
|
|
311
|
+
* memory rides the repo, so this is a plain branch push). Returns true on success;
|
|
312
|
+
* false when there is no upstream / offline / not a repo. */
|
|
313
|
+
export function pushCurrentBranch(root) {
|
|
314
|
+
try {
|
|
315
|
+
execFileSync("git", ["-C", root, "push"], { stdio: "ignore" });
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** Revert a single memory move locally (no push). Returns true on success. A
|
|
323
|
+
* conflicting revert is aborted so the working tree is never left half-reverted. */
|
|
324
|
+
export function revertMemoryMove(sha, root) {
|
|
325
|
+
try {
|
|
326
|
+
execFileSync("git", ["-C", root, "revert", "--no-edit", sha], { stdio: "ignore" });
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
try {
|
|
331
|
+
execFileSync("git", ["-C", root, "revert", "--abort"], { stdio: "ignore" });
|
|
332
|
+
}
|
|
333
|
+
catch { /* nothing to abort */ }
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
269
337
|
/** Full metadata + changed files for a commit. */
|
|
270
338
|
export function commitMeta(sha, cwd) {
|
|
271
339
|
const raw = gitSafe(["show", "-s", "--format=%H%x1f%h%x1f%s%x1f%b%x1f%an%x1f%aI", sha], cwd);
|
|
@@ -274,6 +342,46 @@ export function commitMeta(sha, cwd) {
|
|
|
274
342
|
const [full = "", short = "", subject = "", body = "", author = "", date = ""] = raw.split("\x1f");
|
|
275
343
|
return { sha: full, shortSha: short, subject, body, author, date, files: commitFiles(sha, cwd) };
|
|
276
344
|
}
|
|
345
|
+
/** First-parent and exact blob seams for deterministic before/after analysis.
|
|
346
|
+
* They never check out a ref or mutate the active worktree. */
|
|
347
|
+
export function firstParent(sha, cwd) {
|
|
348
|
+
const row = gitSafe(["rev-list", "--parents", "-n", "1", sha], cwd);
|
|
349
|
+
const parts = row.split(/\s+/).filter(Boolean);
|
|
350
|
+
return parts.length > 1 ? parts[1] : null;
|
|
351
|
+
}
|
|
352
|
+
export function fileAtRef(ref, file, cwd) {
|
|
353
|
+
return gitRawSafe(["show", `${ref}:${file}`], cwd);
|
|
354
|
+
}
|
|
355
|
+
/** Name-status records for one commit, rename-aware and NUL-delimited so paths
|
|
356
|
+
* with whitespace cannot corrupt the parser. */
|
|
357
|
+
export function commitChanges(sha, cwd) {
|
|
358
|
+
const raw = gitRawSafe(["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", "-M", "-z", sha], cwd);
|
|
359
|
+
if (raw == null)
|
|
360
|
+
return [];
|
|
361
|
+
const fields = raw.split("\0").filter((v) => v !== "");
|
|
362
|
+
const out = [];
|
|
363
|
+
for (let i = 0; i < fields.length;) {
|
|
364
|
+
const code = fields[i++];
|
|
365
|
+
const kind = code[0];
|
|
366
|
+
if (kind === "R" || kind === "C") {
|
|
367
|
+
const before = fields[i++] ?? null;
|
|
368
|
+
const after = fields[i++] ?? null;
|
|
369
|
+
if (before && after)
|
|
370
|
+
out.push({ status: kind === "R" ? "renamed" : "copied", before, after });
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const file = fields[i++] ?? null;
|
|
374
|
+
if (!file)
|
|
375
|
+
continue;
|
|
376
|
+
if (kind === "A")
|
|
377
|
+
out.push({ status: "added", before: null, after: file });
|
|
378
|
+
else if (kind === "D")
|
|
379
|
+
out.push({ status: "deleted", before: file, after: null });
|
|
380
|
+
else
|
|
381
|
+
out.push({ status: "modified", before: file, after: file });
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
277
385
|
/** Machine-generated paths that carry no design "why" — lockfiles, build output,
|
|
278
386
|
* vendored deps, snapshots, source maps. Excluded from synthesis diffs via git
|
|
279
387
|
* pathspec BEFORE git assembles/orders the patch: a huge lockfile sorts ahead of
|
|
@@ -320,6 +428,18 @@ export function lastCommitForFile(file, cwd) {
|
|
|
320
428
|
const sha = gitSafe(["log", "-1", "--format=%h", "--", file], cwd);
|
|
321
429
|
return sha ? `commit:${sha}` : "";
|
|
322
430
|
}
|
|
431
|
+
/** Full SHA of the commit that introduced a path. Unlike lastCommitForFile this
|
|
432
|
+
* remains stable when lifecycle/proof updates later touch the same policy file. */
|
|
433
|
+
export function firstCommitForFile(file, cwd) {
|
|
434
|
+
// Deliberately do NOT use --follow: content-similar, immutable-ID JSON policy
|
|
435
|
+
// files can be misclassified as renames of one another, moving valid_from to a
|
|
436
|
+
// different policy's introduction commit.
|
|
437
|
+
const added = gitSafe(["log", "--diff-filter=A", "--format=%H", "--", file], cwd)
|
|
438
|
+
.split("\n").find(Boolean);
|
|
439
|
+
if (added)
|
|
440
|
+
return added;
|
|
441
|
+
return gitSafe(["log", "--reverse", "--format=%H", "--", file], cwd).split("\n").find(Boolean) ?? "";
|
|
442
|
+
}
|
|
323
443
|
/** ISO author-date of the most recent commit touching a file ("" if none). */
|
|
324
444
|
export function lastChangeDate(file, cwd) {
|
|
325
445
|
return gitSafe(["log", "-1", "--format=%aI", "--", file], cwd);
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
* then runs HunchStore.reindex() to refresh the SQLite index.
|
|
9
9
|
*/
|
|
10
10
|
import { readFileSync, statSync, readdirSync } from "node:fs";
|
|
11
|
-
import { join, relative,
|
|
11
|
+
import { join, relative, posix } from "node:path";
|
|
12
12
|
import { parseSource, attributeCalls } from "./parse.js";
|
|
13
13
|
import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
|
|
14
|
+
import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
|
|
15
|
+
import { resolveRelativeImport } from "../core/relativeImports.js";
|
|
14
16
|
import { extracted, inferred } from "../core/types.js";
|
|
15
17
|
import { isGitRepo, trackedFiles, fileGitMetrics } from "./git.js";
|
|
16
18
|
const CODE_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
|
|
@@ -82,6 +84,10 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
82
84
|
perFileImports.push({ file: rel, imports: parsed.imports });
|
|
83
85
|
}
|
|
84
86
|
const byId = new Map(symbols.map((s) => [s.id, s]));
|
|
87
|
+
const importedFiles = new Map(perFileImports.map(({ file, imports }) => [
|
|
88
|
+
file,
|
|
89
|
+
new Set(imports.map((specifier) => resolveImport(file, specifier, fileSymbols)).filter((target) => !!target)),
|
|
90
|
+
]));
|
|
85
91
|
// ---- pass 2: resolve calls -> symbol-level edges -------------------------
|
|
86
92
|
const edges = [];
|
|
87
93
|
const edgeSeen = new Set();
|
|
@@ -100,7 +106,7 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
100
106
|
continue;
|
|
101
107
|
const callerName = byId.get(callerId)?.name ?? "?";
|
|
102
108
|
for (const [calleeName, memberOnly] of callees) {
|
|
103
|
-
const calleeId = resolveName(calleeName, file, nameIndex, byId);
|
|
109
|
+
const calleeId = resolveName(calleeName, file, importedFiles.get(file) ?? new Set(), nameIndex, byId);
|
|
104
110
|
if (!calleeId || calleeId === callerId)
|
|
105
111
|
continue;
|
|
106
112
|
// A member call `x.foo()` only yields an edge when `foo` resolves to a
|
|
@@ -146,17 +152,31 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
146
152
|
continue;
|
|
147
153
|
for (const spec of imports) {
|
|
148
154
|
const target = resolveImport(file, spec, fileSymbols);
|
|
149
|
-
if (
|
|
155
|
+
if (target) {
|
|
156
|
+
const toCmp = fileToComponent.get(target);
|
|
157
|
+
if (!toCmp || toCmp === fromCmp)
|
|
158
|
+
continue;
|
|
159
|
+
addEdge({
|
|
160
|
+
id: edgeId(fromCmp, toCmp, "depends_on"),
|
|
161
|
+
from: fromCmp, to: toCmp, type: "depends_on",
|
|
162
|
+
reason: `${file} imports ${target}`, strength: 0.6,
|
|
163
|
+
provenance: extracted(0.9, [`${file}:imports:${spec}`]),
|
|
164
|
+
});
|
|
150
165
|
continue;
|
|
151
|
-
|
|
152
|
-
|
|
166
|
+
}
|
|
167
|
+
const dependency = externalPackage(spec);
|
|
168
|
+
const external = externalImportNodeId(spec);
|
|
169
|
+
const anchors = [...(fileSymbols.get(file) ?? [])].sort();
|
|
170
|
+
if (!dependency || !external || !anchors.length)
|
|
153
171
|
continue;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
172
|
+
for (const anchor of anchors) {
|
|
173
|
+
addEdge({
|
|
174
|
+
id: edgeId(anchor, external, "imports"),
|
|
175
|
+
from: anchor, to: external, type: "imports",
|
|
176
|
+
reason: `${file} imports external package ${dependency}`, strength: 1,
|
|
177
|
+
provenance: extracted(1, [`${file}:imports:${spec}`]),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
160
180
|
}
|
|
161
181
|
}
|
|
162
182
|
// persist
|
|
@@ -215,8 +235,11 @@ function listCodeFiles(root) {
|
|
|
215
235
|
walk(root);
|
|
216
236
|
return out;
|
|
217
237
|
}
|
|
218
|
-
/** Resolve a callee name to a symbol id: prefer same-file,
|
|
219
|
-
|
|
238
|
+
/** Resolve a callee name to a symbol id: prefer same-file, otherwise require a
|
|
239
|
+
* unique symbol in a statically imported local file. A unique repository-wide
|
|
240
|
+
* name is not evidence of a binding: callback parameters and built-ins often
|
|
241
|
+
* share names with unrelated exported symbols. */
|
|
242
|
+
function resolveName(name, file, importedFiles, nameIndex, byId) {
|
|
220
243
|
const candidates = nameIndex.get(name);
|
|
221
244
|
if (!candidates || candidates.length === 0)
|
|
222
245
|
return null;
|
|
@@ -225,34 +248,12 @@ function resolveName(name, file, nameIndex, byId) {
|
|
|
225
248
|
return sameFile[0];
|
|
226
249
|
if (sameFile.length > 1)
|
|
227
250
|
return null; // ambiguous within the file — don't guess
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
// ambiguous across files — skip to avoid wrong edges (keeps the graph clean)
|
|
231
|
-
return null;
|
|
251
|
+
const imported = candidates.filter((id) => importedFiles.has(byId.get(id)?.file ?? ""));
|
|
252
|
+
return imported.length === 1 ? imported[0] : null;
|
|
232
253
|
}
|
|
233
254
|
/** Resolve a relative import specifier to a concrete tracked file path. */
|
|
234
255
|
function resolveImport(fromFile, spec, fileSymbols) {
|
|
235
|
-
|
|
236
|
-
return null; // external package
|
|
237
|
-
const base = toPosix(join(dirname(fromFile), spec));
|
|
238
|
-
// Prefer TS source rewrites over the literal `.js` specifier: in a TS repo an
|
|
239
|
-
// import of "./db.js" resolves to db.ts. Only fall back to the literal path.
|
|
240
|
-
const candidates = [
|
|
241
|
-
base.replace(/\.js$/, ".ts"),
|
|
242
|
-
base.replace(/\.js$/, ".tsx"),
|
|
243
|
-
base.replace(/\.jsx$/, ".tsx"),
|
|
244
|
-
base + ".ts",
|
|
245
|
-
base + ".tsx",
|
|
246
|
-
base,
|
|
247
|
-
base + ".js",
|
|
248
|
-
toPosix(join(base, "index.ts")),
|
|
249
|
-
toPosix(join(base, "index.tsx")),
|
|
250
|
-
toPosix(join(base, "index.js")),
|
|
251
|
-
];
|
|
252
|
-
for (const c of candidates)
|
|
253
|
-
if (fileSymbols.has(c))
|
|
254
|
-
return c;
|
|
255
|
-
return null;
|
|
256
|
+
return resolveRelativeImport(fromFile, spec, fileSymbols.keys()).path;
|
|
256
257
|
}
|
|
257
258
|
/** Derive components from the directory layout: the directory immediately under
|
|
258
259
|
* `src/` (or the top-level dir) groups files into a module component. */
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
const runtimeRequire = createRequire(import.meta.url);
|
|
6
|
+
const COPY_PREFIX = "hunch-tree-sitter-";
|
|
7
|
+
const NATIVE_PACKAGES = ["tree-sitter", "tree-sitter-typescript"];
|
|
8
|
+
let runtime = null;
|
|
9
|
+
function processIsAlive(pid) {
|
|
10
|
+
if (pid === process.pid)
|
|
11
|
+
return true;
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
return error.code === "EPERM";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function removeStaleCopies() {
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = readdirSync(tmpdir(), { withFileTypes: true });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (!entry.isDirectory())
|
|
30
|
+
continue;
|
|
31
|
+
const match = new RegExp(`^${COPY_PREFIX}(\\d+)-`).exec(entry.name);
|
|
32
|
+
if (!match || processIsAlive(Number(match[1])))
|
|
33
|
+
continue;
|
|
34
|
+
try {
|
|
35
|
+
rmSync(join(tmpdir(), entry.name), { recursive: true, force: true, maxRetries: 2 });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Another process may have won the cleanup race, or Windows may still be
|
|
39
|
+
// releasing a just-exited native module. A later process can retry.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function environmentKey(packageName) {
|
|
44
|
+
return `${packageName.toUpperCase().replaceAll("-", "_")}_PREBUILD`;
|
|
45
|
+
}
|
|
46
|
+
function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
|
|
47
|
+
const packageRoot = dirname(runtimeRequire.resolve(`${packageName}/package.json`));
|
|
48
|
+
const source = nodeGypBuild.path(packageRoot);
|
|
49
|
+
const packageCopy = join(copyRoot, packageName);
|
|
50
|
+
const normalized = source.replaceAll("\\", "/");
|
|
51
|
+
const prebuild = /\/prebuilds\/([^/]+)\/[^/]+$/.exec(normalized);
|
|
52
|
+
const destination = prebuild
|
|
53
|
+
? join(packageCopy, "prebuilds", prebuild[1], basename(source))
|
|
54
|
+
: join(packageCopy, "build", "Release", basename(source));
|
|
55
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
56
|
+
copyFileSync(source, destination);
|
|
57
|
+
return packageCopy;
|
|
58
|
+
}
|
|
59
|
+
/** Load both native tree-sitter addons from process-owned temp copies. Windows
|
|
60
|
+
* keeps loaded `.node` files locked for the process lifetime; redirecting the
|
|
61
|
+
* upstream loaders means npm can replace the installed package during an active
|
|
62
|
+
* MCP session without killing that session or falling back to a stale binary. */
|
|
63
|
+
export function loadNativeTreeSitter() {
|
|
64
|
+
if (runtime)
|
|
65
|
+
return runtime;
|
|
66
|
+
const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /tree-sitter(?:-typescript)?\.node$/.test(path)
|
|
67
|
+
&& !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
|
|
68
|
+
if (preloaded.length) {
|
|
69
|
+
throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
|
|
70
|
+
}
|
|
71
|
+
removeStaleCopies();
|
|
72
|
+
const copyRoot = mkdtempSync(join(tmpdir(), `${COPY_PREFIX}${process.pid}-`));
|
|
73
|
+
const nodeGypBuild = runtimeRequire("node-gyp-build");
|
|
74
|
+
const previous = new Map();
|
|
75
|
+
try {
|
|
76
|
+
for (const packageName of NATIVE_PACKAGES) {
|
|
77
|
+
const key = environmentKey(packageName);
|
|
78
|
+
previous.set(key, process.env[key]);
|
|
79
|
+
process.env[key] = copyNativeBinding(packageName, copyRoot, nodeGypBuild);
|
|
80
|
+
}
|
|
81
|
+
const Parser = runtimeRequire("tree-sitter");
|
|
82
|
+
const languages = runtimeRequire("tree-sitter-typescript");
|
|
83
|
+
runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
try {
|
|
87
|
+
rmSync(copyRoot, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
catch { /* best effort */ }
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
for (const [key, value] of previous) {
|
|
94
|
+
if (value === undefined)
|
|
95
|
+
delete process.env[key];
|
|
96
|
+
else
|
|
97
|
+
process.env[key] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
process.once("exit", () => {
|
|
101
|
+
try {
|
|
102
|
+
rmSync(copyRoot, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
catch { /* next process prunes it */ }
|
|
105
|
+
});
|
|
106
|
+
return runtime;
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=nativeTreeSitter.js.map
|
package/dist/extractors/parse.js
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* - symbols: functions, methods, classes, interfaces, types, arrow-fn consts
|
|
4
|
-
* - imports: module specifiers (for dependency edges)
|
|
5
|
-
* - calls: callee names + byte offset (mapped to the enclosing symbol)
|
|
6
|
-
*
|
|
7
|
-
* Uses NATIVE tree-sitter (synchronous, prebuilt for Node 20 — see decision in
|
|
8
|
-
* the commit history; web-tree-sitter's WASM grammars had an incompatible ABI).
|
|
9
|
-
*/
|
|
10
|
-
import Parser from "tree-sitter";
|
|
11
|
-
import TS from "tree-sitter-typescript";
|
|
12
|
-
const { typescript, tsx } = TS;
|
|
1
|
+
import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
|
|
2
|
+
const { Parser, typescript, tsx } = loadNativeTreeSitter();
|
|
13
3
|
/** Extremely common builtin/array/object/string/promise method names. Member
|
|
14
4
|
* calls to these (e.g. `arr.map(...)`) must NOT create call edges to unrelated
|
|
15
5
|
* repo symbols that happen to share the name (DESIGN: keep the graph clean). */
|
|
@@ -112,12 +102,12 @@ export function parseSource(file, source) {
|
|
|
112
102
|
imports.push(node.text.replace(STR_QUOTES, ""));
|
|
113
103
|
}
|
|
114
104
|
else if (cname === "call.id") {
|
|
115
|
-
calls.push({ callee: node.text, atByte: node.startIndex, member: false });
|
|
105
|
+
calls.push({ callee: node.text, atByte: node.startIndex, endByte: node.endIndex, member: false });
|
|
116
106
|
}
|
|
117
107
|
else if (cname === "call.member") {
|
|
118
108
|
// skip builtin method names to avoid false edges to similarly-named symbols
|
|
119
109
|
if (!BUILTIN_METHODS.has(node.text))
|
|
120
|
-
calls.push({ callee: node.text, atByte: node.startIndex, member: true });
|
|
110
|
+
calls.push({ callee: node.text, atByte: node.startIndex, endByte: node.endIndex, member: true });
|
|
121
111
|
}
|
|
122
112
|
}
|
|
123
113
|
for (const { kind, def, name } of pendingDefs.values()) {
|
|
@@ -131,7 +121,7 @@ export function parseSource(file, source) {
|
|
|
131
121
|
});
|
|
132
122
|
}
|
|
133
123
|
symbols.sort((a, b) => a.startByte - b.startByte);
|
|
134
|
-
return { symbols, imports, calls };
|
|
124
|
+
return { symbols, imports, calls, parseable: !tree.rootNode.hasError };
|
|
135
125
|
}
|
|
136
126
|
/** Walk up to the nearest node whose type is a definition we recognize. */
|
|
137
127
|
function ascendToDef(node) {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
8
|
import { wikiSummary } from "../wiki/wiki.js";
|
|
9
|
+
import { PolicyRepository } from "../constitution/repository.js";
|
|
9
10
|
const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
|
|
10
11
|
const END = "<!-- HUNCH:END -->";
|
|
11
12
|
export function renderHunchSection(store, root) {
|
|
@@ -18,6 +19,7 @@ export function renderHunchSection(store, root) {
|
|
|
18
19
|
bugs: store.json.loadAll("bugs").length,
|
|
19
20
|
constraints: store.json.loadAll("constraints").length,
|
|
20
21
|
components: store.json.loadAll("components").length,
|
|
22
|
+
policies: root ? new PolicyRepository(root, store).listPolicies({ publicOnly: true }).length : 0,
|
|
21
23
|
};
|
|
22
24
|
const lines = [];
|
|
23
25
|
lines.push(START);
|
|
@@ -25,7 +27,7 @@ export function renderHunchSection(store, root) {
|
|
|
25
27
|
lines.push("");
|
|
26
28
|
lines.push("This repo has **Hunch** — a curated graph of *why* the code is the way it is " +
|
|
27
29
|
"(decisions, bug history, invariants). It currently holds " +
|
|
28
|
-
`**${counts.decisions} decisions, ${counts.bugs} bugs, ${counts.constraints} constraints, ${counts.components} components**.`);
|
|
30
|
+
`**${counts.decisions} decisions, ${counts.bugs} bugs, ${counts.constraints} constraints, ${counts.components} components, ${counts.policies} policies**.`);
|
|
29
31
|
lines.push("");
|
|
30
32
|
lines.push("**Consult Hunch via the `hunch_*` MCP tools — pick by MOMENT, not from memory:**");
|
|
31
33
|
lines.push("");
|
|
@@ -47,8 +49,13 @@ export function renderHunchSection(store, root) {
|
|
|
47
49
|
lines.push("");
|
|
48
50
|
lines.push("**Before committing / merging:**");
|
|
49
51
|
lines.push("- `hunch_conformance()` — does the code still SATISFY recorded intent? Run before and after a refactor.");
|
|
52
|
+
lines.push("- `hunch_policy_evaluate(policy_id?, active_only?)` / `hunch_policy_plan(policy_id)` / `hunch_policy_card(policy_id)` / `hunch_policy_proof(policy_id)` — evaluate canonical policy, inspect the planned corpus, review the evidence/uncertainty card, and inspect raw replay receipts; only an explicit human activation grants authority.");
|
|
50
53
|
lines.push("- `hunch_pr_impact(base?)` / `hunch_merge_verdict(...)` — a change's memory surface; would it re-open a closed bug?");
|
|
51
54
|
lines.push("");
|
|
55
|
+
lines.push("**Build the Constitution review queue:**");
|
|
56
|
+
lines.push("- `hunch constitution bootstrap --since 90d --max-candidates 3` (CLI) — normalize recent structured human evidence into at most three non-active policy candidates; add `--history` for exact, human-identifier-grounded fix/revert deltas or explicit dependency retirements. Coincidence/ambiguity stays uncompilable; neither path grants authority.");
|
|
57
|
+
lines.push("- `hunch constitution ingest --since 90d [--instructions] [--from export.json]` (CLI) — normalize corrections/failures plus bounded committed instructions/ADRs and strict local review/conversation/PR exports into Git-native evidence; raw prose is hash-only, unsupported intent remains uncompilable, and no policy is minted.");
|
|
58
|
+
lines.push("");
|
|
52
59
|
lines.push("**After deciding / when corrected:**");
|
|
53
60
|
lines.push("- `hunch_capture_decision(topic?)` → `hunch_record_decision(...)` — interview first, then write; status `proposed` = roadmap intent (shows in `hunch now`).");
|
|
54
61
|
lines.push("- `hunch_record_correction(...)` — a human correction becomes an ENFORCED rule (Never Twice), not a one-session memory.");
|
|
@@ -19,6 +19,7 @@ const ENTRIES = [
|
|
|
19
19
|
".hunch/*.sqlite-wal",
|
|
20
20
|
".hunch/*.sqlite-journal",
|
|
21
21
|
".hunch/**/*.tmp*",
|
|
22
|
+
".hunch-cache/",
|
|
22
23
|
// Per-machine private-overlay pointer written by `hunch private` (holds the local
|
|
23
24
|
// path to the private store) — never committed.
|
|
24
25
|
".hunch/local.json",
|
|
@@ -39,6 +40,13 @@ const MEM_ENTRIES = [
|
|
|
39
40
|
".hunch/bugs/",
|
|
40
41
|
".hunch/constraints/",
|
|
41
42
|
".hunch/components/",
|
|
43
|
+
".hunch/evidence/",
|
|
44
|
+
".hunch/corpora/",
|
|
45
|
+
".hunch/policies/",
|
|
46
|
+
".hunch/proofs/",
|
|
47
|
+
".hunch/plans/",
|
|
48
|
+
".hunch/dispositions/",
|
|
49
|
+
".hunch/shadow/",
|
|
42
50
|
".hunch/symbols/",
|
|
43
51
|
".hunch/edges/",
|
|
44
52
|
];
|
|
@@ -21,6 +21,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
22
|
import { join, dirname } from "node:path";
|
|
23
23
|
import { renderHunchSection, upsertSection, updateClaudeMd } from "./claudemd.js";
|
|
24
|
+
import { isGitCleanPath } from "../extractors/git.js";
|
|
24
25
|
/** Strip // line and block comments + trailing commas (JSONC → JSON). String-aware
|
|
25
26
|
* (double-quoted, with escapes) so a // inside a value isn't mangled. VS Code's
|
|
26
27
|
* .vscode/mcp.json is JSONC, so we must tolerate comments. */
|
|
@@ -405,23 +406,24 @@ export function regenerateGrounding(root, store) {
|
|
|
405
406
|
writeWindsurfRule(root, store),
|
|
406
407
|
];
|
|
407
408
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
* a file (so it can't scaffold grounding into a project that opted out of an
|
|
411
|
-
* assistant). Run by `hunch index` and non-hook `hunch sync` so a project silently
|
|
412
|
-
* picks up generator fixes (e.g. corrected MCP tool param names) and fresh record
|
|
413
|
-
* counts on the next refresh — no manual `hunch init`. Not run from the commit hook,
|
|
414
|
-
* which deliberately avoids dirtying the working tree on every commit. */
|
|
415
|
-
export function refreshExistingGrounding(root, store) {
|
|
416
|
-
const targets = [
|
|
409
|
+
function groundingTargets(root, store) {
|
|
410
|
+
return [
|
|
417
411
|
["CLAUDE.md", () => updateClaudeMd(root, store)],
|
|
418
412
|
["AGENTS.md", () => writeAgentsMd(root, store)],
|
|
419
413
|
[join(".github", "copilot-instructions.md"), () => writeCopilotInstructions(root, store)],
|
|
420
414
|
[join(".cursor", "rules", "hunch.mdc"), () => writeCursorRule(root, store)],
|
|
421
415
|
[join(".windsurf", "rules", "hunch.md"), () => writeWindsurfRule(root, store)],
|
|
422
416
|
];
|
|
417
|
+
}
|
|
418
|
+
/** Self-heal: refresh the Hunch section in each grounding doc that ALREADY exists,
|
|
419
|
+
* and report which ones actually changed. Unlike regenerateGrounding it NEVER creates
|
|
420
|
+
* a file (so it can't scaffold grounding into a project that opted out of an
|
|
421
|
+
* assistant). Run by `hunch index` and non-hook `hunch sync` so a project silently
|
|
422
|
+
* picks up generator fixes (e.g. corrected MCP tool param names) and fresh record
|
|
423
|
+
* counts on the next refresh — no manual `hunch init`. */
|
|
424
|
+
export function refreshExistingGrounding(root, store) {
|
|
423
425
|
const changed = [];
|
|
424
|
-
for (const [rel, write] of
|
|
426
|
+
for (const [rel, write] of groundingTargets(root, store)) {
|
|
425
427
|
const file = join(root, rel);
|
|
426
428
|
if (!existsSync(file))
|
|
427
429
|
continue; // refresh-only: never scaffold a doc the project doesn't have
|
|
@@ -432,6 +434,26 @@ export function refreshExistingGrounding(root, store) {
|
|
|
432
434
|
}
|
|
433
435
|
return changed;
|
|
434
436
|
}
|
|
437
|
+
/** Capture-commit refresh: rewrite ONLY grounding docs that are git-clean, and return the
|
|
438
|
+
* absolute paths of the ones that changed so the caller folds them into the memory commit
|
|
439
|
+
* (commitAndPushHunch alsoStage). This keeps committed record counts permanently true —
|
|
440
|
+
* every capture used to bump the count and re-stale the committed docs, failing the
|
|
441
|
+
* release gate's clean-tree check on the next CI index (the refresh-counts treadmill).
|
|
442
|
+
* A user-dirty or untracked doc is left completely untouched (never refreshed, never
|
|
443
|
+
* staged); it heals on the next manual `hunch sync` or `hunch index`. */
|
|
444
|
+
export function refreshCommittableGrounding(root, store) {
|
|
445
|
+
const changed = [];
|
|
446
|
+
for (const [rel, write] of groundingTargets(root, store)) {
|
|
447
|
+
const file = join(root, rel);
|
|
448
|
+
if (!existsSync(file) || !isGitCleanPath(root, rel))
|
|
449
|
+
continue;
|
|
450
|
+
const before = readFileSync(file, "utf8");
|
|
451
|
+
write();
|
|
452
|
+
if (readFileSync(file, "utf8") !== before)
|
|
453
|
+
changed.push(file);
|
|
454
|
+
}
|
|
455
|
+
return changed;
|
|
456
|
+
}
|
|
435
457
|
/** A malformed configuration for one surface (for example an MCP file) must not
|
|
436
458
|
* prevent the same assistant's rule or lifecycle hook from being installed. */
|
|
437
459
|
function runProvider(writers) {
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single funnel for auto-committing memory after a capture, so memory never has to be
|
|
3
|
+
* committed by hand — in EVERY mode (auto-commit is ON by default; `--no-auto-commit` opts out).
|
|
4
|
+
* A private record commits + two-way-syncs (merge remote, then push) its dedicated overlay repo.
|
|
5
|
+
* A public record commits the repo-tracked .hunch/ WITHOUT pushing — an automatic pull/push on
|
|
6
|
+
* the user's CODE repo would merge the remote into their working branch and publish unpushed
|
|
7
|
+
* commits (bug_overlay_clobber); the memory commit rides their next push instead.
|
|
8
|
+
* Used by every CLI/MCP path that writes a record.
|
|
9
|
+
*/
|
|
10
|
+
import { dirname } from "node:path";
|
|
1
11
|
import { commitAndPushHunch } from "../extractors/git.js";
|
|
12
|
+
import { refreshCommittableGrounding } from "./providers.js";
|
|
2
13
|
/** Auto-commit + push the overlay after a private write, when auto-commit is on. No-op
|
|
3
14
|
* otherwise (manual `hunch private --sync` still works). Never throws. */
|
|
4
15
|
export function flushPrivate(store, message) {
|
|
@@ -21,6 +32,10 @@ export function flushCapture(store, publicHunchDir, isPrivate, message) {
|
|
|
21
32
|
}
|
|
22
33
|
if (!store.autoCommit)
|
|
23
34
|
return null;
|
|
24
|
-
|
|
35
|
+
// A public capture changes record counts, so refresh git-clean grounding docs and fold
|
|
36
|
+
// them into the SAME memory commit — otherwise every capture re-stales the committed
|
|
37
|
+
// counts and the release gate's clean-tree check fails on the next CI index.
|
|
38
|
+
const grounding = refreshCommittableGrounding(dirname(publicHunchDir), store);
|
|
39
|
+
return commitAndPushHunch(publicHunchDir, message, { push: false, alsoStage: grounding });
|
|
25
40
|
}
|
|
26
41
|
//# sourceMappingURL=sync.js.map
|