@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
@@ -31,6 +31,7 @@ export const MIGRATIONS = [
31
31
  // losslessly — no record is dropped, and `valid_from` is populated BEFORE the
32
32
  // Zod pass. Defensive: input is untrusted JSON.
33
33
  version: 2,
34
+ kinds: ["decisions", "constraints"],
34
35
  description: "Add valid_from/valid_to/superseded_by/retired (decisions) and status/valid_from/valid_to (constraints)",
35
36
  up(kind, raw) {
36
37
  if (kind === "decisions") {
@@ -64,6 +65,7 @@ export const MIGRATIONS = [
64
65
  // relationship schema is validated; newly written resource relationships use
65
66
  // hunch.resource-relationship/1 and keep their currentness/metadata.
66
67
  version: 3,
68
+ kinds: ["edges"],
67
69
  description: "Version existing graph edges before adding Engineering Landscape resources and relationships",
68
70
  up(kind, raw) {
69
71
  if (kind === "edges") {
@@ -98,15 +100,24 @@ export function writeManifest(paths, version = SCHEMA_VERSION) {
98
100
  writeFileAtomic(paths.manifest, JSON.stringify({ schema_version: version }, null, 2) + "\n");
99
101
  }
100
102
  /** Apply every migration in (fromVersion, toVersion] to a single raw record. Skips
101
- * non-object input untouched (the loader's Zod pass will reject it). */
103
+ * non-object input untouched (the loader's Zod pass will reject it). All-or-nothing:
104
+ * the steps run on a deep copy, so `raw` is never mutated and a caller that rejects
105
+ * the result (or a step that throws) keeps the original record intact — never a
106
+ * half-migrated one. */
102
107
  export function migrateRaw(kind, raw, fromVersion, migrations = MIGRATIONS, toVersion = SCHEMA_VERSION) {
103
108
  if (raw === null || typeof raw !== "object" || Array.isArray(raw))
104
109
  return raw;
105
- let rec = raw;
106
- for (const m of [...migrations].sort((a, b) => a.version - b.version)) {
107
- if (m.version > fromVersion && m.version <= toVersion)
108
- rec = m.up(kind, rec);
109
- }
110
+ const steps = migrations.filter((m) => m.version > fromVersion && m.version <= toVersion);
111
+ if (steps.length === 0)
112
+ return raw; // nothing to apply: no copy needed
113
+ let rec = structuredClone(raw);
114
+ for (const m of steps.sort((a, b) => a.version - b.version))
115
+ rec = m.up(kind, rec);
110
116
  return rec;
111
117
  }
118
+ /** Whether some migration in (fromVersion, toVersion] may change a record of
119
+ * `kind` — i.e. whether re-running migrations could ever fix a failed one. */
120
+ export function hasPendingMigration(kind, fromVersion, migrations = MIGRATIONS, toVersion = SCHEMA_VERSION) {
121
+ return migrations.some((m) => m.version > fromVersion && m.version <= toVersion && (!m.kinds || m.kinds.includes(kind)));
122
+ }
112
123
  //# sourceMappingURL=migrate.js.map
@@ -1,10 +1,9 @@
1
1
  export declare const HUNCH_DIR = ".hunch";
2
- /** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
3
- * path with "/" — git emits it on all OSes — so any user- or agent-supplied
4
- * target must be normalized before comparison, otherwise a Windows caller passing
5
- * `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
6
- * symbol names too: they contain no backslashes. This does NOT make a path
7
- * repo-relative — an absolute path passes through with its separators flipped,
2
+ /** Convert a user- or agent-supplied path/target to forward-slash form. Hunch
3
+ * stores every path with "/" (git emits it on every OS), so a Windows-style
4
+ * backslash path would otherwise never match the stored form. Harmless on
5
+ * symbol names, which carry no backslashes. It does NOT make a path
6
+ * repo-relative: an absolute path only has its separators flipped and is left
8
7
  * unchanged otherwise; use `repoRelativeTarget` for that.
9
8
  *
10
9
  * Inherently ambiguous for a string like `docs/notes\notes.md`: it could be a
@@ -15,25 +14,19 @@ export declare const HUNCH_DIR = ".hunch";
15
14
  * needs the correct answer for a real file should decide from that evidence
16
15
  * instead of trusting this blindly. */
17
16
  export declare function toPosixTarget(target: string): string;
18
- /** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
19
- * to Write, or a glob whose concrete segments aren't literal): resolve the longest
20
- * existing ancestor, then re-append the missing tail. Idempotent on an
21
- * already-resolved path. */
17
+ /** Canonical real path of `p`, tolerant of paths that do not exist yet (a file
18
+ * about to be written, a glob with non-literal segments): the longest existing
19
+ * ancestor is resolved through the native realpath and the missing trailing
20
+ * segments are re-appended unchanged. Terminates at the filesystem root,
21
+ * is idempotent on a resolved path and never throws for a missing one. */
22
22
  export declare function realpathNorm(p: string): string;
23
- /** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
24
- * `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
25
- * repo-relative, so a caller that hands over an absolute edit-payload path
26
- * matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
27
- * returned empty for an absolute target that plainly had a matching rule). A
28
- * relative target, a glob, or an absolute path outside `root` passes through
29
- * unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
30
- *
31
- * BOTH ends are realpath-normalized before the relative-path computation: on
32
- * macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
33
- * hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
34
- * bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
35
- * caller treats the file as outside the repo, silently dropping all context
36
- * (dec_e0a36efbf5). */
23
+ /** Rewrite an ABSOLUTE `target` to repo-relative forward-slash form when it lies
24
+ * inside `root`. Stored Hunch paths are always repo-relative, so an absolute
25
+ * path lifted from an edit payload otherwise matches nothing (issue #296).
26
+ * Both ends go through `realpathNorm`, so a symlinked root (macOS /var vs
27
+ * /private/var, /tmp, a symlinked home) cancels out (dec_e0a36efbf5). Anything
28
+ * with no safe repo-relative form (the root itself, an escape, a drive-letter
29
+ * remainder) is returned in its normalized form unchanged. */
37
30
  export declare function repoRelativeTarget(target: string, root: string): string;
38
31
  /** True when `target` (already repo-relative POSIX form) names a regular FILE
39
32
  * that really exists inside `root`. The LAST-RESORT half of the "is this a real
@@ -3,12 +3,11 @@ import { join } from "node:path";
3
3
  import { existsSync, realpathSync, statSync } from "node:fs";
4
4
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
5
  export const HUNCH_DIR = ".hunch";
6
- /** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
7
- * path with "/" — git emits it on all OSes — so any user- or agent-supplied
8
- * target must be normalized before comparison, otherwise a Windows caller passing
9
- * `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
10
- * symbol names too: they contain no backslashes. This does NOT make a path
11
- * repo-relative — an absolute path passes through with its separators flipped,
6
+ /** Convert a user- or agent-supplied path/target to forward-slash form. Hunch
7
+ * stores every path with "/" (git emits it on every OS), so a Windows-style
8
+ * backslash path would otherwise never match the stored form. Harmless on
9
+ * symbol names, which carry no backslashes. It does NOT make a path
10
+ * repo-relative: an absolute path only has its separators flipped and is left
12
11
  * unchanged otherwise; use `repoRelativeTarget` for that.
13
12
  *
14
13
  * Inherently ambiguous for a string like `docs/notes\notes.md`: it could be a
@@ -21,47 +20,42 @@ export const HUNCH_DIR = ".hunch";
21
20
  export function toPosixTarget(target) {
22
21
  return target.replace(/\\/g, "/").replace(/^\.\//, "");
23
22
  }
24
- /** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
25
- * to Write, or a glob whose concrete segments aren't literal): resolve the longest
26
- * existing ancestor, then re-append the missing tail. Idempotent on an
27
- * already-resolved path. */
23
+ /** Canonical real path of `p`, tolerant of paths that do not exist yet (a file
24
+ * about to be written, a glob with non-literal segments): the longest existing
25
+ * ancestor is resolved through the native realpath and the missing trailing
26
+ * segments are re-appended unchanged. Terminates at the filesystem root,
27
+ * is idempotent on a resolved path and never throws for a missing one. */
28
28
  export function realpathNorm(p) {
29
- try {
30
- return realpathSync.native(p);
31
- }
32
- catch {
33
- const parent = dirname(p);
34
- if (parent === p)
35
- return p; // hit the root; nothing more to resolve
36
- return join(realpathNorm(parent), basename(p));
29
+ const tail = [];
30
+ let cur = p;
31
+ for (;;) {
32
+ try {
33
+ const real = realpathSync.native(cur);
34
+ return tail.length ? join(real, ...tail.reverse()) : real;
35
+ }
36
+ catch {
37
+ const parent = dirname(cur);
38
+ if (parent === cur)
39
+ return p;
40
+ tail.push(basename(cur));
41
+ cur = parent;
42
+ }
37
43
  }
38
44
  }
39
- /** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
40
- * `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
41
- * repo-relative, so a caller that hands over an absolute edit-payload path
42
- * matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
43
- * returned empty for an absolute target that plainly had a matching rule). A
44
- * relative target, a glob, or an absolute path outside `root` passes through
45
- * unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
46
- *
47
- * BOTH ends are realpath-normalized before the relative-path computation: on
48
- * macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
49
- * hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
50
- * bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
51
- * caller treats the file as outside the repo, silently dropping all context
52
- * (dec_e0a36efbf5). */
45
+ /** Rewrite an ABSOLUTE `target` to repo-relative forward-slash form when it lies
46
+ * inside `root`. Stored Hunch paths are always repo-relative, so an absolute
47
+ * path lifted from an edit payload otherwise matches nothing (issue #296).
48
+ * Both ends go through `realpathNorm`, so a symlinked root (macOS /var vs
49
+ * /private/var, /tmp, a symlinked home) cancels out (dec_e0a36efbf5). Anything
50
+ * with no safe repo-relative form (the root itself, an escape, a drive-letter
51
+ * remainder) is returned in its normalized form unchanged. */
53
52
  export function repoRelativeTarget(target, root) {
54
- const t = toPosixTarget(target);
55
- const looksAbsolute = isAbsolute(t) || /^[a-zA-Z]:/.test(t);
56
- if (!looksAbsolute)
57
- return t;
58
- // `root` is posix'd too, not just `target`: realpathNorm's fallback walk decomposes
59
- // by "/" (via node:path, which on a POSIX host does NOT treat "\" as a separator),
60
- // so an un-posix'd root and a posix'd target would decompose into differently-shaped
61
- // ancestor chains and never share a common realpath'd prefix.
62
- const rel = toPosixTarget(relative(realpathNorm(toPosixTarget(root)), realpathNorm(t)));
63
- if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
64
- return t;
53
+ const norm = toPosixTarget(target);
54
+ if (!isAbsolute(norm) && !/^[A-Za-z]:/.test(norm))
55
+ return norm;
56
+ const rel = relative(realpathNorm(toPosixTarget(root)), realpathNorm(norm)).replace(/\\/g, "/");
57
+ if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[A-Za-z]:/.test(rel))
58
+ return norm;
65
59
  return rel;
66
60
  }
67
61
  /** True when `target` (already repo-relative POSIX form) names a regular FILE
@@ -81,7 +81,17 @@ function gitText(root, args) {
81
81
  function exactCommit(root, ref) {
82
82
  if (!ref.trim() || /[\0\r\n]/.test(ref) || ref.length > 1_024)
83
83
  throw new Error("Git revision is invalid");
84
- const revision = gitText(root, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
84
+ let revision;
85
+ try {
86
+ revision = gitText(root, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
87
+ }
88
+ catch (error) {
89
+ // Git's own text ("fatal: Needed a single revision") never names the ref (#493);
90
+ // any other failure keeps its original message.
91
+ if (!/Needed a single revision|unknown revision|bad revision/i.test(error.message))
92
+ throw error;
93
+ throw new Error(`Git revision ${JSON.stringify(ref)} does not name a commit in this repository`);
94
+ }
85
95
  if (!GIT_OBJECT.test(revision))
86
96
  throw new Error("Git did not return an exact commit object");
87
97
  return revision;
@@ -12,10 +12,11 @@ export interface DecisionReferenceRepair {
12
12
  relatedFiles: number;
13
13
  evidence: number;
14
14
  }
15
- export declare function replaceExact(values: string[], from: string, to: string): {
15
+ export type ExactReplacement = {
16
16
  values: string[];
17
17
  changed: number;
18
18
  };
19
+ export declare function replaceExact(refs: string[], target: string, replacement: string): ExactReplacement;
19
20
  /**
20
21
  * Return a corrected copy of a decision, or `null` when the source reference is
21
22
  * not present. The decision's semantic content and verification timestamp are
@@ -1,10 +1,10 @@
1
- export function replaceExact(values, from, to) {
1
+ export function replaceExact(refs, target, replacement) {
2
2
  let changed = 0;
3
- const replaced = values.map((value) => {
4
- if (value !== from)
3
+ const replaced = refs.map((value) => {
4
+ if (value !== target)
5
5
  return value;
6
6
  changed++;
7
- return to;
7
+ return replacement;
8
8
  });
9
9
  // A decision may already cite the destination. Keep the reference list a set
10
10
  // after the repair so a correction cannot create duplicate scope/evidence.
@@ -26,8 +26,12 @@ export function relativeImportCandidates(fromFile, specifier) {
26
26
  * indexer compatibility; callers that need ambiguity metadata can inspect the
27
27
  * returned matches instead of guessing a different target. */
28
28
  export function resolveRelativeImport(fromFile, specifier, availableFiles) {
29
- const available = new Set(availableFiles);
30
- const matches = relativeImportCandidates(fromFile, specifier).filter((candidate) => available.has(candidate));
29
+ // Bare/non-relative specifiers have no candidates: skip the O(N) file-set copy.
30
+ const candidates = relativeImportCandidates(fromFile, specifier);
31
+ if (candidates.length === 0)
32
+ return { path: null, matches: [] };
33
+ const available = availableFiles instanceof Set ? availableFiles : new Set(availableFiles);
34
+ const matches = candidates.filter((candidate) => available.has(candidate));
31
35
  return { path: matches[0] ?? null, matches };
32
36
  }
33
37
  //# sourceMappingURL=relativeImports.js.map
@@ -1,40 +1,23 @@
1
- import { type CommitRewrite, type DroppedRewrite } from "./commitrepair.js";
2
- /** Tolerant read: a missing or corrupt queue file is treated as empty — this
3
- * is local scratch state, never a source of truth worth failing loudly over. */
1
+ import type { CommitRewrite, DroppedRewrite } from "./commitrepair.js";
2
+ /** The raw queue. Missing, corrupt or non-array files read as `[]`; never throws. */
4
3
  export declare function readPendingRepairs(root: string): CommitRewrite[];
4
+ /** Atomically replace the queue. Writing `[]` clears it. */
5
5
  export declare function writePendingRepairs(root: string, rewrites: readonly CommitRewrite[]): void;
6
- /** Tolerant read, same discipline as readPendingRepairs. */
6
+ /** The tombstones of rewrites rejected with `--drop`. Same tolerance as the queue. */
7
7
  export declare function readDroppedRepairs(root: string): DroppedRewrite[];
8
+ /** Atomically replace the tombstone file. Writing `[]` clears it. */
8
9
  export declare function writeDroppedRepairs(root: string, dropped: readonly DroppedRewrite[]): void;
9
- /** The queue as every READ-ONLY consumer should see it: raw entries minus
10
- * anything a human already rejected via `--drop`, regardless of how a
11
- * tombstoned entry ended up back in the raw queue file (e.g. the post-merge
12
- * hook's backgrounded detection racing a `--drop`). A read-time filter, not
13
- * a mutation — the raw queue file is untouched.
14
- *
15
- * `repair-provenance`'s own action is the one exception: it needs the RAW
16
- * read (via readPendingRepairs) because it diffs raw-vs-swept to report what
17
- * it cleaned up, and it persists the sweep back to disk. Every other
18
- * reader — `hunch escalations`, SessionStart orientation, the `hunch_now`
19
- * and `hunch_escalations` MCP tools — must call this instead, or a
20
- * tombstoned entry that hasn't yet been swept by a `repair-provenance` run
21
- * re-surfaces as if the human never answered it. */
10
+ /**
11
+ * The raw queue minus anything tombstoned. Read-only: it never changes either
12
+ * file. Every read-only consumer uses this -- `hunch escalations`, SessionStart
13
+ * orientation, and the `hunch_now` and `hunch_escalations` MCP tools. Only
14
+ * `repair-provenance` itself reads the raw queue.
15
+ */
22
16
  export declare function readActivePendingRepairs(root: string): CommitRewrite[];
23
- /** The `queued` entries whose proposed `to` doesn't resolve to a real commit
24
- * in this repository — the same withheldForUnresolvableTo gate
25
- * `repair-provenance --apply` itself runs before writing, surfaced here so
26
- * a READ-ONLY consumer (hunch escalations, hunch_now/hunch_escalations,
27
- * SessionStart orientation) can tell a permanently-stuck entry apart from
28
- * one `--apply` can still resolve, instead of advertising a
29
- * `--apply --only <id>` that's guaranteed to no-op forever. `null` from
30
- * commitsExist (the check itself failed to run) is treated as fail-open —
31
- * same discipline as the apply path — so nothing is misreported as
32
- * withheld just because the check couldn't run.
33
- *
34
- * Returns the OBJECTS, not their ids, and every caller must pass the exact
35
- * same `queued` array into both this function and whatever it hands the
36
- * result to (commitRepairEscalations) — an id-keyed set would collapse a
37
- * corrupted queue's two same-id entries (one resolvable, one not) into one
38
- * unit, exactly the identity confusion withheldForUnresolvableTo's own
39
- * docstring warns about. */
17
+ /**
18
+ * The queued entries whose proposed `to` does not resolve in this repository.
19
+ * Fails open (nothing withheld) when git cannot answer. The set holds the
20
+ * caller's own objects, so callers must pass the same `queued` array here and to
21
+ * whatever consumes the result.
22
+ */
40
23
  export declare function withheldRewrites(root: string, queued: readonly CommitRewrite[]): Set<CommitRewrite>;
@@ -1,131 +1,81 @@
1
1
  /**
2
- * Local-only queue of commit-provenance repairs detected opportunistically
3
- * (typically by the post-merge hook, src/integrations/hooks.ts) but not yet
4
- * applied. `.hunch/pending-commit-repairs.json` is gitignored the same way
5
- * `.hunch/local.json` is — never committed, never pushed. It exists because
6
- * the opportunistic detection window is narrow: `ORIG_HEAD` gets overwritten
7
- * by the next merge, and the matched-away commit can eventually be gc'd —
8
- * queuing the exact {id, from, to} triple here lets a human confirm the match
9
- * later (`hunch repair-provenance --apply`) without losing it.
2
+ * Local-only queue of detected-but-not-yet-applied commit-provenance repairs.
10
3
  *
11
- * `.hunch/dropped-commit-repairs.json` is the sibling tombstone file: a human
12
- * rejecting a queued match via `--drop` records the exact {id, from, to}
13
- * triple here, durably, so detection re-deriving the identical match on a
14
- * later merge doesn't re-queue what was already rejected — a genuinely
15
- * different replacement (`to`) for the same still-orphaned commit is a new
16
- * proposal and still surfaces (see commitrepair.ts's withoutDropped). Same
17
- * local-only, gitignored discipline as the queue.
4
+ * It is filled opportunistically, usually by the post-merge hook, and is
5
+ * gitignored like `.hunch/local.json` -- never committed. It exists because the
6
+ * detection window is narrow: `ORIG_HEAD` gets overwritten and the matched-away
7
+ * commit can be garbage-collected. A human confirms later with
8
+ * `hunch repair-provenance --apply`. A sibling tombstone file records the exact
9
+ * `{id, from, to}` triples rejected with `--drop`, so re-detection does not
10
+ * re-queue them.
18
11
  */
19
12
  import { existsSync, readFileSync } from "node:fs";
20
13
  import { join } from "node:path";
21
14
  import { writeFileAtomic } from "./io.js";
22
15
  import { withoutDropped, withheldForUnresolvableTo } from "./commitrepair.js";
23
16
  import { commitsExist } from "../extractors/git.js";
24
- function queuePath(root) {
25
- return join(root, ".hunch", "pending-commit-repairs.json");
26
- }
27
- function droppedPath(root) {
28
- return join(root, ".hunch", "dropped-commit-repairs.json");
29
- }
30
- function isCommitRewrite(value) {
31
- if (!value || typeof value !== "object")
17
+ const queuePath = (root) => join(root, ".hunch", "pending-commit-repairs.json");
18
+ const droppedPath = (root) => join(root, ".hunch", "dropped-commit-repairs.json");
19
+ const nonEmpty = (v) => typeof v === "string" && v.length > 0;
20
+ /**
21
+ * Shape-only validation: non-empty string id/from/to, and `to !== from` (a no-op
22
+ * is rejected). There is deliberately no hex check -- a bad `to` is caught later
23
+ * by the apply-time existence check, so this loader stays shape-only.
24
+ */
25
+ function isRewrite(v) {
26
+ if (typeof v !== "object" || v === null)
32
27
  return false;
33
- const { id, from, to } = value;
34
- // Non-empty, and to !== from — an empty target is never a useful rewrite,
35
- // and a no-op entry would apply "successfully" while accomplishing nothing.
36
- // Deliberately NOT a hex-shape check: from/to are opaque strings elsewhere
37
- // in this codebase's test fixtures, and repairDecisionCommit's own
38
- // `d.commit === mine.from` guard already makes a garbage `from` inert (it
39
- // can never match a real decision). A garbage `to` is caught elsewhere,
40
- // not here: `hunch repair-provenance --apply` runs commitsExist against
41
- // every `to` it's actually about to act on (the whole queue, or just the
42
- // one entry `--only <id>` targets) before repairDecisionCommit ever sees
43
- // it, and leaves a non-resolving entry queued rather than applying it
44
- // (withheldForUnresolvableTo, src/core/commitrepair.ts) — with the caveat
45
- // that commitsExist itself fails OPEN if the check can't run at all (not a
46
- // git repo, git missing, timeout), same as drift.ts's own use of it. This
47
- // filter stays shape-only on purpose — the existence check needs a
48
- // repository to run against, which this read-only queue loader doesn't
49
- // have.
50
- return typeof id === "string" && !!id
51
- && typeof from === "string" && !!from
52
- && typeof to === "string" && !!to
53
- && to !== from;
28
+ const r = v;
29
+ return nonEmpty(r.id) && nonEmpty(r.from) && nonEmpty(r.to) && r.to !== r.from;
54
30
  }
55
- function isDroppedRewrite(value) {
56
- if (!value || typeof value !== "object")
31
+ function isDropped(v) {
32
+ if (typeof v !== "object" || v === null)
57
33
  return false;
58
- const { id, from, to } = value;
59
- return typeof id === "string" && !!id
60
- && typeof from === "string" && !!from
61
- && typeof to === "string" && !!to;
34
+ const r = v;
35
+ return nonEmpty(r.id) && nonEmpty(r.from) && nonEmpty(r.to);
62
36
  }
63
- /** Tolerant read: a missing or corrupt queue file is treated as empty — this
64
- * is local scratch state, never a source of truth worth failing loudly over. */
65
- export function readPendingRepairs(root) {
66
- const path = queuePath(root);
67
- if (!existsSync(path))
37
+ function readList(file, guard) {
38
+ if (!existsSync(file))
68
39
  return [];
69
40
  try {
70
- const parsed = JSON.parse(readFileSync(path, "utf8"));
71
- return Array.isArray(parsed) ? parsed.filter(isCommitRewrite) : [];
41
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
42
+ return Array.isArray(parsed) ? parsed.filter(guard) : [];
72
43
  }
73
44
  catch {
74
45
  return [];
75
46
  }
76
47
  }
48
+ /** The raw queue. Missing, corrupt or non-array files read as `[]`; never throws. */
49
+ export function readPendingRepairs(root) {
50
+ return readList(queuePath(root), isRewrite);
51
+ }
52
+ /** Atomically replace the queue. Writing `[]` clears it. */
77
53
  export function writePendingRepairs(root, rewrites) {
78
54
  writeFileAtomic(queuePath(root), JSON.stringify(rewrites, null, 2) + "\n");
79
55
  }
80
- /** Tolerant read, same discipline as readPendingRepairs. */
56
+ /** The tombstones of rewrites rejected with `--drop`. Same tolerance as the queue. */
81
57
  export function readDroppedRepairs(root) {
82
- const path = droppedPath(root);
83
- if (!existsSync(path))
84
- return [];
85
- try {
86
- const parsed = JSON.parse(readFileSync(path, "utf8"));
87
- return Array.isArray(parsed) ? parsed.filter(isDroppedRewrite) : [];
88
- }
89
- catch {
90
- return [];
91
- }
58
+ return readList(droppedPath(root), isDropped);
92
59
  }
60
+ /** Atomically replace the tombstone file. Writing `[]` clears it. */
93
61
  export function writeDroppedRepairs(root, dropped) {
94
62
  writeFileAtomic(droppedPath(root), JSON.stringify(dropped, null, 2) + "\n");
95
63
  }
96
- /** The queue as every READ-ONLY consumer should see it: raw entries minus
97
- * anything a human already rejected via `--drop`, regardless of how a
98
- * tombstoned entry ended up back in the raw queue file (e.g. the post-merge
99
- * hook's backgrounded detection racing a `--drop`). A read-time filter, not
100
- * a mutation — the raw queue file is untouched.
101
- *
102
- * `repair-provenance`'s own action is the one exception: it needs the RAW
103
- * read (via readPendingRepairs) because it diffs raw-vs-swept to report what
104
- * it cleaned up, and it persists the sweep back to disk. Every other
105
- * reader — `hunch escalations`, SessionStart orientation, the `hunch_now`
106
- * and `hunch_escalations` MCP tools — must call this instead, or a
107
- * tombstoned entry that hasn't yet been swept by a `repair-provenance` run
108
- * re-surfaces as if the human never answered it. */
64
+ /**
65
+ * The raw queue minus anything tombstoned. Read-only: it never changes either
66
+ * file. Every read-only consumer uses this -- `hunch escalations`, SessionStart
67
+ * orientation, and the `hunch_now` and `hunch_escalations` MCP tools. Only
68
+ * `repair-provenance` itself reads the raw queue.
69
+ */
109
70
  export function readActivePendingRepairs(root) {
110
71
  return withoutDropped(readPendingRepairs(root), readDroppedRepairs(root));
111
72
  }
112
- /** The `queued` entries whose proposed `to` doesn't resolve to a real commit
113
- * in this repository — the same withheldForUnresolvableTo gate
114
- * `repair-provenance --apply` itself runs before writing, surfaced here so
115
- * a READ-ONLY consumer (hunch escalations, hunch_now/hunch_escalations,
116
- * SessionStart orientation) can tell a permanently-stuck entry apart from
117
- * one `--apply` can still resolve, instead of advertising a
118
- * `--apply --only <id>` that's guaranteed to no-op forever. `null` from
119
- * commitsExist (the check itself failed to run) is treated as fail-open —
120
- * same discipline as the apply path — so nothing is misreported as
121
- * withheld just because the check couldn't run.
122
- *
123
- * Returns the OBJECTS, not their ids, and every caller must pass the exact
124
- * same `queued` array into both this function and whatever it hands the
125
- * result to (commitRepairEscalations) — an id-keyed set would collapse a
126
- * corrupted queue's two same-id entries (one resolvable, one not) into one
127
- * unit, exactly the identity confusion withheldForUnresolvableTo's own
128
- * docstring warns about. */
73
+ /**
74
+ * The queued entries whose proposed `to` does not resolve in this repository.
75
+ * Fails open (nothing withheld) when git cannot answer. The set holds the
76
+ * caller's own objects, so callers must pass the same `queued` array here and to
77
+ * whatever consumes the result.
78
+ */
129
79
  export function withheldRewrites(root, queued) {
130
80
  const existing = commitsExist(queued.map((r) => r.to), root);
131
81
  return new Set(withheldForUnresolvableTo(queued, existing).withheld);
@@ -190,6 +190,39 @@ function scanIdentifiers(text) {
190
190
  export function identifierTokens(text) {
191
191
  return new Set(scanIdentifiers(text).keys());
192
192
  }
193
+ /** Word -> the `id:` terms of the camel compounds the prompt splits it out of (PowerShell ->
194
+ * power, shell; GitHubActions -> git, hub, actions), sorted so prompt word order never matters.
195
+ * A word the prompt also uses on its own belongs to no compound. */
196
+ function camelCompoundGroups(text) {
197
+ const groups = new Map();
198
+ const compounds = text.match(CAMEL_IDENTIFIER) ?? [];
199
+ if (!compounds.length)
200
+ return groups;
201
+ const standalone = lexicalTokens(text.replace(CAMEL_IDENTIFIER, " "));
202
+ for (const m of compounds) {
203
+ const key = ID_PREFIX + m.toLowerCase();
204
+ for (const word of lexicalTokens(m)) {
205
+ if (standalone.has(word))
206
+ continue;
207
+ const keys = groups.get(word) ?? [];
208
+ if (!keys.includes(key))
209
+ keys.push(key);
210
+ groups.set(word, keys.sort());
211
+ }
212
+ }
213
+ return groups;
214
+ }
215
+ /** A pasted stack frame: `at fn (file:line:col)`, `at file:line:col`, `at X (<anonymous>)`, `at X (node:...)`.
216
+ * Prose that merely starts with "at" ("at startup the lock goes stale") has no such tail. */
217
+ const FRAME_LINE = /^\s*at\s+\S+.*(?::\d+:\d+\)?|\(<anonymous>\)|\(node:[^)]*\))\s*$/;
218
+ /** Text whose words count: every line, unless the prompt holds an actual trace (2+ frame
219
+ * lines), whose frames name library internals (process, tick, sync), not the task. A Node
220
+ * warning's `(node:<pid>)` prefix is noise either way; the words after it stay. */
221
+ function promptWordText(prompt) {
222
+ const lines = prompt.split(/\r?\n/);
223
+ const trace = lines.filter((line) => FRAME_LINE.test(line)).length >= 2;
224
+ return (trace ? lines.filter((line) => !FRAME_LINE.test(line)) : lines).join("\n").replace(/\(node:\d+\)/g, " ");
225
+ }
193
226
  const termTokens = (text) => new Set([...lexicalTokens(text), ...identifierTokens(text)]);
194
227
  /** Score live records against one prompt. Text fields per kind (title + rationale):
195
228
  * decision title/context/decision; bug title/symptom/root_cause; constraint
@@ -199,9 +232,9 @@ const termTokens = (text) => new Set([...lexicalTokens(text), ...identifierToken
199
232
  export function selectForTask(records, promptText, opts = {}) {
200
233
  const k = Math.max(0, opts.k ?? DEFAULT_K);
201
234
  // Stack-frame lines name library APIs (writeFileSync, processTicksAndRejections), not the task:
202
- // keep their words, but extract no identifiers from them.
203
- const promptIdentifiers = (promptText ?? "").split(/\r?\n/).filter((line) => !/^\s*at\s+\S/.test(line) && !/node:internal|\(node:/.test(line)).join("\n");
204
- const taskTerms = new Set([...lexicalTokens(promptText ?? ""), ...identifierTokens(promptIdentifiers)]);
235
+ // when the prompt holds a real trace, neither their words nor their identifiers count.
236
+ const promptIdentifiers = promptWordText(promptText ?? "");
237
+ const taskTerms = new Set([...lexicalTokens(promptIdentifiers), ...identifierTokens(promptIdentifiers)]);
205
238
  // Without an existence check (pure/offline) only a slashed token is trusted as a
206
239
  // path: "e.g.", "Node.js" or "v1.2" would otherwise match a broad glob.
207
240
  const paths = promptPaths(promptText ?? "", opts.root).filter((p) => opts.pathExists ? opts.pathExists(p) : p.includes("/"));
@@ -230,6 +263,15 @@ export function selectForTask(records, promptText, opts = {}) {
230
263
  for (const c of candidates)
231
264
  for (const term of c.tokens)
232
265
  df.set(term, (df.get(term) ?? 0) + 1);
266
+ // A camel compound in many records is a brand (GitHub, PowerShell, GitHubActions): in a record
267
+ // holding it, its parts count as one word. A near-unique one (df <= MIN_DF_CAP, e.g. QuotaReaper)
268
+ // names this project, and in a record without the compound a part is an ordinary word.
269
+ const wordGroups = new Map();
270
+ for (const [word, keys] of camelCompoundGroups(promptIdentifiers)) {
271
+ const common = keys.filter((key) => (df.get(key) ?? 0) > MIN_DF_CAP);
272
+ if (common.length)
273
+ wordGroups.set(word, common);
274
+ }
233
275
  const strongIds = new Set([...scanIdentifiers(promptIdentifiers)].filter(([, strong]) => strong).map(([term]) => term));
234
276
  const rareTerms = new Map();
235
277
  for (const term of taskTerms) {
@@ -247,7 +289,7 @@ export function selectForTask(records, promptText, opts = {}) {
247
289
  }
248
290
  const scored = [];
249
291
  for (const { tokens, anchors, ...rest } of candidates) {
250
- let words = 0;
292
+ const words = new Set();
251
293
  let identifiers = 0;
252
294
  let termScore = 0;
253
295
  for (const [term, { weight, strong }] of rareTerms) {
@@ -256,12 +298,12 @@ export function selectForTask(records, promptText, opts = {}) {
256
298
  if (strong)
257
299
  identifiers++;
258
300
  else
259
- words++;
301
+ words.add(wordGroups.get(term)?.find((key) => tokens.has(key)) ?? term);
260
302
  termScore += weight;
261
303
  }
262
304
  const pathHits = paths.filter((p) => anchors.some((anchor) => pathMatchesGlob(p, anchor))).length;
263
305
  // One rare identifier alone qualifies (it counts double); a lone rare word does not.
264
- if (pathHits > 0 || words + IDENTIFIER_WEIGHT * identifiers >= MIN_DISTINCT_TERMS)
306
+ if (pathHits > 0 || words.size + IDENTIFIER_WEIGHT * identifiers >= MIN_DISTINCT_TERMS)
265
307
  scored.push({ ...rest, pathHits, termScore });
266
308
  }
267
309
  // A record anchored to more of the files the prompt names outranks one that