@davesheffer/hunch 1.45.2 → 1.47.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.
Files changed (91) hide show
  1. package/dist/cli/index.js +290 -353
  2. package/dist/cli/invocation.d.ts +22 -13
  3. package/dist/cli/invocation.js +49 -29
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/experiment.d.ts +1 -1
  8. package/dist/constitution/sourceMutation.js +28 -18
  9. package/dist/core/agenthook.js +26 -6
  10. package/dist/core/boundedSpawn.d.ts +57 -0
  11. package/dist/core/boundedSpawn.js +321 -0
  12. package/dist/core/commitrepair.d.ts +43 -138
  13. package/dist/core/commitrepair.js +76 -167
  14. package/dist/core/config.d.ts +6 -0
  15. package/dist/core/config.js +1 -0
  16. package/dist/core/correction.js +9 -9
  17. package/dist/core/delivery.d.ts +6 -1
  18. package/dist/core/delivery.js +71 -5
  19. package/dist/core/docanchors.js +9 -9
  20. package/dist/core/drift.js +22 -27
  21. package/dist/core/escalations.d.ts +16 -58
  22. package/dist/core/escalations.js +39 -121
  23. package/dist/core/glob.d.ts +7 -7
  24. package/dist/core/glob.js +12 -11
  25. package/dist/core/groundingLag.d.ts +2 -9
  26. package/dist/core/groundingLag.js +5 -11
  27. package/dist/core/groundingMerge.d.ts +2 -7
  28. package/dist/core/groundingMerge.js +35 -54
  29. package/dist/core/migrate.d.ts +10 -1
  30. package/dist/core/migrate.js +17 -6
  31. package/dist/core/paths.d.ts +17 -24
  32. package/dist/core/paths.js +37 -43
  33. package/dist/core/projectDna.js +11 -1
  34. package/dist/core/refrepair.d.ts +2 -1
  35. package/dist/core/refrepair.js +4 -4
  36. package/dist/core/relativeImports.js +6 -2
  37. package/dist/core/repairqueue.d.ts +17 -34
  38. package/dist/core/repairqueue.js +48 -98
  39. package/dist/core/taskSelection.js +48 -6
  40. package/dist/core/testSelection.d.ts +122 -0
  41. package/dist/core/testSelection.js +588 -0
  42. package/dist/core/types.d.ts +3 -3
  43. package/dist/core/types.js +1 -1
  44. package/dist/extractors/diff.d.ts +4 -4
  45. package/dist/extractors/diff.js +25 -29
  46. package/dist/extractors/git.d.ts +8 -23
  47. package/dist/extractors/git.js +77 -95
  48. package/dist/extractors/helm.d.ts +10 -0
  49. package/dist/extractors/helm.js +43 -59
  50. package/dist/extractors/indexer.d.ts +6 -0
  51. package/dist/extractors/indexer.js +168 -184
  52. package/dist/extractors/k8sManifest.d.ts +23 -80
  53. package/dist/extractors/k8sManifest.js +319 -690
  54. package/dist/extractors/languages.d.ts +37 -37
  55. package/dist/extractors/languages.js +83 -54
  56. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  57. package/dist/extractors/nativeTreeSitter.js +17 -14
  58. package/dist/extractors/parse.d.ts +27 -13
  59. package/dist/extractors/parse.js +120 -56
  60. package/dist/extractors/workspaces.js +3 -1
  61. package/dist/integrations/claudemd.js +1 -0
  62. package/dist/integrations/gitignore.js +3 -5
  63. package/dist/integrations/health.d.ts +6 -0
  64. package/dist/integrations/health.js +33 -12
  65. package/dist/integrations/hooks.d.ts +13 -16
  66. package/dist/integrations/hooks.js +31 -30
  67. package/dist/integrations/mergeDriver.js +18 -17
  68. package/dist/integrations/optout.d.ts +3 -0
  69. package/dist/integrations/optout.js +17 -0
  70. package/dist/integrations/providers.d.ts +6 -2
  71. package/dist/integrations/providers.js +19 -2
  72. package/dist/integrations/team.js +4 -2
  73. package/dist/integrations/uninstall.d.ts +10 -4
  74. package/dist/integrations/uninstall.js +22 -18
  75. package/dist/mcp/server.d.ts +4 -5
  76. package/dist/mcp/server.js +107 -81
  77. package/dist/serve/app.js +9 -1
  78. package/dist/serve/config.js +5 -0
  79. package/dist/serve/writelock.d.ts +3 -1
  80. package/dist/serve/writelock.js +17 -3
  81. package/dist/store/hunchStore.d.ts +2 -1
  82. package/dist/store/hunchStore.js +20 -23
  83. package/dist/store/jsonStore.d.ts +13 -3
  84. package/dist/store/jsonStore.js +37 -13
  85. package/dist/store/stateBinding.js +17 -4
  86. package/dist/synthesis/provider.d.ts +19 -21
  87. package/dist/synthesis/provider.js +93 -154
  88. package/dist/synthesis/synthesize.d.ts +8 -8
  89. package/dist/synthesis/synthesize.js +33 -50
  90. package/package.json +3 -3
  91. package/server.json +2 -2
@@ -1,29 +1,29 @@
1
1
  /**
2
2
  * Wires up two independent git merge drivers:
3
- * - `merge=hunch` — the structured `.hunch/` JSON driver (store/merge.ts):
4
- * resolves concurrent edits by record id.
5
- * - `merge=hunch-grounding` — the generated grounding docs (core/groundingMerge.ts):
6
- * auto-resolves a hard conflict confined to the record-counts sentence,
7
- * leaving any other conflict untouched (dec_ba5b0dfa22).
8
- * Both routes live in `.gitattributes` (committed, travels with the repo);
9
- * both drivers are registered in LOCAL git config only (per-clone, since each
10
- * references this machine's node + cli path — teammates re-run `hunch init`).
3
+ * - `merge=hunch`: the structured `.hunch/` JSON driver (store/merge.ts), which
4
+ * resolves concurrent edits by record id;
5
+ * - `merge=hunch-grounding`: the generated grounding docs driver
6
+ * (core/groundingMerge.ts), which auto-resolves only a hard conflict confined
7
+ * to the record-counts sentence.
8
+ * Both routes live in the committed `.gitattributes`. Both drivers are
9
+ * registered only in local, per-clone git config, because each references this
10
+ * machine's node and CLI path; teammates re-run `hunch init`.
11
11
  */
12
12
  import { execFileSync } from "node:child_process";
13
13
  import { readFileSync, existsSync } from "node:fs";
14
14
  import { writeFileAtomic } from "../core/io.js";
15
15
  import { assertSafeTopLevelConfigFile } from "./gitignore.js";
16
16
  import { GROUNDING_DOC_PATHS } from "./providers.js";
17
+ import { mergeDriverShell } from "../cli/invocation.js";
17
18
  // Route the .hunch JSON records through the structured driver — but NOT the
18
19
  // manifest (an id-less `{schema_version}` object the driver can't merge by id; a
19
20
  // normal text merge with conflict markers is the right behavior for it).
20
- //
21
- // The five generated grounding docs get the OTHER driver — narrower in scope
22
- // (it only ever touches a hard conflict confined to the counts sentence).
21
+ // The five generated grounding docs go to the other driver, which is narrower:
22
+ // it only ever touches a hard conflict confined to the counts sentence.
23
23
  export const ATTR_LINES = [
24
24
  ".hunch/**/*.json merge=hunch",
25
25
  ".hunch/manifest.json merge=text",
26
- ...GROUNDING_DOC_PATHS.map((f) => `${f} merge=hunch-grounding`),
26
+ ...GROUNDING_DOC_PATHS.map((p) => `${p} merge=hunch-grounding`),
27
27
  ];
28
28
  export function targetRepositoryEnv() {
29
29
  const env = { ...process.env };
@@ -56,11 +56,12 @@ export function installMergeDriver(root, invShell) {
56
56
  assertSafeTopLevelConfigFile(root, ".gitattributes");
57
57
  writeFileAtomic(attrPath, text);
58
58
  }
59
- // 2. Local git config — the driver definitions are per-clone (they reference
60
- // this machine's node + cli path), so they are NOT committed; teammates
61
- // re-run init.
62
- const driver = `${invShell} merge-driver "%O" "%A" "%B" "%P"`;
63
- const groundingDriver = `${invShell} merge-driver-grounding "%O" "%A" "%B" "%P"`;
59
+ // 2. The driver definitions go into local git config. They are per-clone (they
60
+ // reference this machine's node and CLI path), so they are never committed; teammates
61
+ // re-run init. Dev mode swaps `npx tsx` for Hunch's own loader (#492).
62
+ const shell = mergeDriverShell(invShell);
63
+ const driver = `${shell} merge-driver "%O" "%A" "%B" "%P"`;
64
+ const groundingDriver = `${shell} merge-driver-grounding "%O" "%A" "%B" "%P"`;
64
65
  const env = targetRepositoryEnv();
65
66
  try {
66
67
  execFileSync("git", ["config", "merge.hunch.name", "hunch structured JSON merge"], { cwd: root, env });
@@ -0,0 +1,3 @@
1
+ /** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
2
+ export declare const OPT_OUT_FILE = ".hunch-removed";
3
+ export declare function isOptedOut(root: string): boolean;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The opt-out marker `hunch uninstall` leaves (#449), apart from uninstall.ts so the
3
+ * hook installers can honor it without an import cycle (uninstall.ts imports hooks.ts).
4
+ */
5
+ import { lstatSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ /** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
8
+ export const OPT_OUT_FILE = ".hunch-removed";
9
+ export function isOptedOut(root) {
10
+ try {
11
+ return lstatSync(join(root, OPT_OUT_FILE)).isFile();
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ //# sourceMappingURL=optout.js.map
@@ -73,8 +73,12 @@ export declare function writeCodexHooks(root: string, inv: Invocation): string;
73
73
  export declare function writeVscodeHooks(root: string, inv: Invocation): string;
74
74
  /** Windsurf's documented workspace hooks. It only supports deterministic
75
75
  * pre-hook blocking via exit code 2, so Hunch uses rules + MCP for context and
76
- * reserves the hook for strict edit protection and pipeline observation. */
77
- export declare function writeWindsurfHooks(root: string, inv: Invocation): string;
76
+ * reserves the hook for strict edit protection and pipeline observation.
77
+ * Devin Desktop (formerly Windsurf) reads .devin/hooks.json and ignores the
78
+ * legacy .windsurf/hooks.json whenever the former defines any hook, so Hunch
79
+ * merges into both (docs.devin.ai/desktop/cascade/hooks, #494). Both files are
80
+ * parsed before either is written: an unparseable one refuses the whole write. */
81
+ export declare function writeWindsurfHooks(root: string, inv: Invocation): string[];
78
82
  /** Antigravity keeps hook groups at the top level (not under `hooks`). Hunch
79
83
  * owns only the `hunch` group and replaces its own old entries idempotently. */
80
84
  export declare function writeAntigravityHooks(root: string, inv: Invocation): string;
@@ -419,9 +419,26 @@ function publishedPinEvents(file) {
419
419
  }
420
420
  /** Windsurf's documented workspace hooks. It only supports deterministic
421
421
  * pre-hook blocking via exit code 2, so Hunch uses rules + MCP for context and
422
- * reserves the hook for strict edit protection and pipeline observation. */
422
+ * reserves the hook for strict edit protection and pipeline observation.
423
+ * Devin Desktop (formerly Windsurf) reads .devin/hooks.json and ignores the
424
+ * legacy .windsurf/hooks.json whenever the former defines any hook, so Hunch
425
+ * merges into both (docs.devin.ai/desktop/cascade/hooks, #494). Both files are
426
+ * parsed before either is written: an unparseable one refuses the whole write. */
423
427
  export function writeWindsurfHooks(root, inv) {
424
- const file = join(root, ".windsurf", "hooks.json");
428
+ const files = [join(root, ".windsurf", "hooks.json"), join(root, ".devin", "hooks.json")];
429
+ // Same shape checks writeHookConfig applies at write time, so neither file is
430
+ // written when the other would refuse.
431
+ for (const file of files) {
432
+ const hooks = objectField(readJsonObj(file), "hooks", file);
433
+ for (const event of WINDSURF_EVENTS) {
434
+ if (hooks[event] !== undefined && !Array.isArray(hooks[event])) {
435
+ throw new Error(`refusing to edit ${file}: hooks.${event} must be an array when present; fix it, then re-run.`);
436
+ }
437
+ }
438
+ }
439
+ return files.map((file) => writeWindsurfHookFile(file, inv));
440
+ }
441
+ function writeWindsurfHookFile(file, inv) {
425
442
  const published = hookCommand(publishedMcpInvocation(), "windsurf");
426
443
  let command = hookCommand(inv, "windsurf");
427
444
  // A source-checkout run (tsx / local dist) must never replace a tracked
@@ -8,6 +8,7 @@
8
8
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync } from "node:fs";
9
9
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
10
10
  import { spawnSync } from "node:child_process";
11
+ import { boundedSpawnSync } from "../core/boundedSpawn.js";
11
12
  import { writeFileAtomic } from "../core/io.js";
12
13
  import { machineFile } from "../core/machine.js";
13
14
  import { hunchTreeAttributesAreSafe, safeOverlayGitTreeListing, safeOverlayTree } from "../core/overlaySafety.js";
@@ -654,13 +655,14 @@ export function cloneValidatedTeamOverlay(sharedRepo, sharedRepoCwd, destination
654
655
  }
655
656
  const cloneEnv = boundedTeamGitEnv();
656
657
  stage = "clone";
657
- const cloned = spawnSync("git", [
658
+ // Bounded so a timeout kills git's whole tree, not just the root (#498).
659
+ const cloned = boundedSpawnSync("git", [
658
660
  "-c", "protocol.ext.allow=never",
659
661
  "-c", `core.hooksPath=${emptyHooks}`,
660
662
  "clone", "--no-checkout", `--template=${emptyTemplate}`,
661
663
  "--", repo, stagedDest,
662
664
  ], {
663
- stdio: "ignore",
665
+ stdout: "ignore",
664
666
  env: cloneEnv,
665
667
  timeout: timeoutMs,
666
668
  });
@@ -1,6 +1,5 @@
1
- /** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
2
- export declare const OPT_OUT_FILE = ".hunch-removed";
3
- export declare function isOptedOut(root: string): boolean;
1
+ import { isOptedOut, OPT_OUT_FILE } from "./optout.js";
2
+ export { isOptedOut, OPT_OUT_FILE };
4
3
  /** Whether `start`, or the repository containing it, opted out: walks up through
5
4
  * `start`'s ancestors (which need not exist yet), stopping after the first one holding
6
5
  * `.git` or at the filesystem root, so a path inside an opted-out repository (`serve
@@ -8,7 +7,14 @@ export declare function isOptedOut(root: string): boolean;
8
7
  * store is deliberately not a boundary: a partition store nested in an opted-out
9
8
  * repository stays gated (fail closed). */
10
9
  export declare function optedOutAt(start: string): boolean;
11
- export declare function optOutMessage(): string;
10
+ /** The opt-out marker that gates `start` (walked as in `optedOutAt`), or null. With
11
+ * `withinRepo`, a marker counts only when the walk reaches a repository: outside one,
12
+ * an ancestor's marker gates nothing. */
13
+ export declare function optOutMarkerAt(start: string, opts?: {
14
+ withinRepo?: boolean;
15
+ }): string | null;
16
+ /** The refusal, naming the marker of the repository actually checked (#488). */
17
+ export declare function optOutMessage(marker?: string): string;
12
18
  export interface UninstallStep {
13
19
  /** Repo-relative (POSIX) where possible, else absolute. */
14
20
  path: string;
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * `.hunch/` holds the engineering memory, so it stays unless `--purge`.
14
14
  */
15
- import { existsSync, lstatSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
15
+ import { existsSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
16
16
  import { spawnSync } from "node:child_process";
17
17
  import { basename, dirname, join, relative, resolve } from "node:path";
18
18
  import { parse as parseToml } from "smol-toml";
@@ -26,16 +26,8 @@ import { gitRun, isInside, planHookRemoval, worktreeTops } from "./hooks.js";
26
26
  import { ATTR_LINES, targetRepositoryEnv } from "./mergeDriver.js";
27
27
  import { antigravityMcpFile, isHunchProviderHook, readJsonObj, TOML_END, TOML_START, windsurfMcpFile } from "./providers.js";
28
28
  import { SLASH_COMMAND_FILES } from "./scaffold.js";
29
- /** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
30
- export const OPT_OUT_FILE = ".hunch-removed";
31
- export function isOptedOut(root) {
32
- try {
33
- return lstatSync(join(root, OPT_OUT_FILE)).isFile();
34
- }
35
- catch {
36
- return false;
37
- }
38
- }
29
+ import { isOptedOut, OPT_OUT_FILE } from "./optout.js";
30
+ export { isOptedOut, OPT_OUT_FILE };
39
31
  /** Whether `start`, or the repository containing it, opted out: walks up through
40
32
  * `start`'s ancestors (which need not exist yet), stopping after the first one holding
41
33
  * `.git` or at the filesystem root, so a path inside an opted-out repository (`serve
@@ -43,19 +35,30 @@ export function isOptedOut(root) {
43
35
  * store is deliberately not a boundary: a partition store nested in an opted-out
44
36
  * repository stays gated (fail closed). */
45
37
  export function optedOutAt(start) {
38
+ return optOutMarkerAt(start) !== null;
39
+ }
40
+ /** The opt-out marker that gates `start` (walked as in `optedOutAt`), or null. With
41
+ * `withinRepo`, a marker counts only when the walk reaches a repository: outside one,
42
+ * an ancestor's marker gates nothing. */
43
+ export function optOutMarkerAt(start, opts = {}) {
44
+ let found = null;
46
45
  for (let dir = resolve(start);;) {
47
- if (isOptedOut(dir))
48
- return true;
46
+ if (!found && isOptedOut(dir)) {
47
+ found = join(dir, OPT_OUT_FILE);
48
+ if (!opts.withinRepo)
49
+ return found;
50
+ }
49
51
  if (existsSync(join(dir, ".git")))
50
- return false;
52
+ return found;
51
53
  const parent = dirname(dir);
52
54
  if (parent === dir)
53
- return false;
55
+ return opts.withinRepo ? null : found;
54
56
  dir = parent;
55
57
  }
56
58
  }
57
- export function optOutMessage() {
58
- return `Hunch was removed from this repository on purpose (${OPT_OUT_FILE}), so it will not set itself up here again. To reinstall, delete ${OPT_OUT_FILE} and run \`hunch init\`.`;
59
+ /** The refusal, naming the marker of the repository actually checked (#488). */
60
+ export function optOutMessage(marker = OPT_OUT_FILE) {
61
+ return `Hunch was removed from this repository on purpose (${marker}), so it will not set itself up here again. To reinstall, delete ${marker} and run \`hunch init\`.`;
59
62
  }
60
63
  function markerText() {
61
64
  return [
@@ -310,6 +313,7 @@ export function planUninstall(root, opts = {}) {
310
313
  p.hookConfig(".codex/hooks.json", providerEntry);
311
314
  p.hookConfig(".github/hooks/hunch.json", providerEntry);
312
315
  p.hookConfig(".windsurf/hooks.json", providerEntry);
316
+ p.hookConfig(".devin/hooks.json", providerEntry);
313
317
  p.json(".agents/hooks.json", "removed the hunch hook group", (json) => {
314
318
  if (!("hunch" in json))
315
319
  return false;
@@ -459,7 +463,7 @@ export function planUninstall(root, opts = {}) {
459
463
  }
460
464
  /** Directories Hunch's writers create; removed only when left empty. Deepest first. */
461
465
  const PRUNE_DIRS = [
462
- ".claude/commands", ".claude", ".cursor/rules", ".cursor", ".windsurf/rules", ".windsurf",
466
+ ".claude/commands", ".claude", ".cursor/rules", ".cursor", ".windsurf/rules", ".windsurf", ".devin",
463
467
  ".vscode", ".github/hooks", ".github/workflows", ".github", ".codex", ".agents",
464
468
  ];
465
469
  export function applyUninstall(plan) {
@@ -359,12 +359,12 @@ declare const DELIVERY_OUTPUT_SCHEMA: z.ZodObject<{
359
359
  type: z.ZodEnum<{
360
360
  depends_on: "depends_on";
361
361
  calls: "calls";
362
+ references: "references";
362
363
  imports: "imports";
363
364
  contains: "contains";
364
365
  implements: "implements";
365
366
  supersedes: "supersedes";
366
367
  related_to: "related_to";
367
- references: "references";
368
368
  provides: "provides";
369
369
  belongs_to: "belongs_to";
370
370
  implemented_by: "implemented_by";
@@ -624,10 +624,9 @@ export declare function compactEnvelope(envelope: z.infer<typeof DELIVERY_OUTPUT
624
624
  * receipts and receipt_id are unchanged. */
625
625
  export declare function compactOmissions(envelope: z.infer<typeof DELIVERY_OUTPUT_SCHEMA>): z.infer<typeof DELIVERY_ADVERTISED_OUTPUT_SCHEMA>;
626
626
  export declare function decisionSubstanceChanged(before: Decision, after: Decision): boolean;
627
- /** Resolve a free-form target (symbol id / name / file path) to symbol records.
628
- * Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
629
- * shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift
630
- * apart on the same question. */
627
+ /** Resolve a free-form target (symbol id, name, or file path) to symbol records using
628
+ * tiered matching — exact id, then exact name, then exact file, then a path-segment-anchored
629
+ * suffix. Shared with HunchStore.why() through matchSymbolsTiered so the two never disagree. */
631
630
  export declare function resolveSymbols(store: HunchStore, target: string): Symbol[];
632
631
  /** Resolve a target to canonical indexed file path(s) (for file-granular blast
633
632
  * radius). Falls back to the literal target so direct-scope checks still run. */