@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,9 +1,10 @@
1
1
  /**
2
- * Deterministic repair for a decision's commit provenance after a squash-merge.
2
+ * Deterministic repair of a decision's commit provenance after a squash-merge.
3
3
  *
4
- * A squash-merge produces one new commit whose parent is the pre-merge target
5
- * tip — the original per-commit SHAs it squashed are never ancestors of it.
6
- * This module matches an orphaned decision to the ONE newly-merged commit
4
+ * A squash-merge lands ONE new commit on the target branch; the original
5
+ * per-commit SHAs are never its ancestors, so a decision citing one of them
6
+ * becomes orphaned. This module matches each orphaned decision to exactly one
7
+ * newly merged commit: the candidate
7
8
  * with the same patch-id, or failing that the ONE whose changed files are a
8
9
  * superset of the decision's related_files. Zero or multiple qualifying
9
10
  * candidates means "don't guess" — same discipline as repair.ts's
@@ -17,22 +18,13 @@ export function commitRepairReviewHash(queued, withheld = new Set()) {
17
18
  const snapshot = queued.map((r) => ({ id: r.id, from: r.from, to: r.to, withheld: withheld.has(r) }));
18
19
  return `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`;
19
20
  }
20
- /** A scope entry is exact (usable as a match key) only when it contains no glob syntax. */
21
- function isExactPath(entry) {
22
- return !!entry && !/[*?[\]{}]/.test(entry);
21
+ /** True when a scope entry can serve as an exact match key (non-empty, no glob metacharacters). */
22
+ function isExactScope(entry) {
23
+ return entry.length > 0 && !/[*?[\]{}]/.test(entry);
23
24
  }
24
- /** Live decisions whose cited commit still exists here but is no longer an
25
- * ancestor of the current tip. Only "orphaned" qualifies: a commit that
26
- * doesn't resolve at all ("unresolvable") is out of repair scope, and a git
27
- * failure ("unknown") must never be mistaken for repair-eligible. */
25
+ /** Live decisions whose cited commit is orphaned (unresolvable/unknown/current are excluded). */
28
26
  export function orphanedCommitDecisions(decisions, status) {
29
- return decisions.filter((d) => {
30
- if (d.status === "superseded" || d.status === "rejected")
31
- return false;
32
- if (!d.commit)
33
- return false;
34
- return status(d.commit) === "orphaned";
35
- });
27
+ return decisions.filter((d) => d.status !== "superseded" && d.status !== "rejected" && !!d.commit && status(d.commit) === "orphaned");
36
28
  }
37
29
  /** Match each orphaned decision to exactly one candidate. A candidate with the
38
30
  * same `git patch-id --stable` as the orphaned commit (a rebase- or
@@ -55,29 +47,16 @@ export function planCommitRepair(orphaned, candidates, patchIdOf = () => null) {
55
47
  if (same.length > 1)
56
48
  continue; // ambiguous: never guess
57
49
  }
58
- const files = (d.related_files ?? []).filter(isExactPath);
59
- if (!files.length)
50
+ const scope = (d.related_files ?? []).filter(isExactScope);
51
+ if (scope.length === 0)
60
52
  continue;
61
- const matches = candidates.filter((c) => files.every((f) => c.files.includes(f)));
62
- if (matches.length !== 1)
63
- continue; // zero or ambiguous: never guess
64
- rewrites.push({ id: d.id, from: d.commit, to: matches[0].sha });
53
+ const supersets = candidates.filter((c) => scope.every((f) => c.files.includes(f)));
54
+ if (supersets.length === 1)
55
+ rewrites.push({ id: d.id, from: d.commit, to: supersets[0].sha });
65
56
  }
66
57
  return { rewrites, records: [...new Set(rewrites.map((r) => r.id))] };
67
58
  }
68
- /** Whether a queued rewrite is still worth ASKING about: the decision it
69
- * names still exists, still cites the commit the entry expects to replace (a
70
- * decision that already moved on would make repairDecisionCommit's own
71
- * stale-plan bail refuse it), and hasn't since been superseded/rejected —
72
- * same exclusion as orphanedCommitDecisions, since repairing dead history's
73
- * provenance isn't worth asking about. Backs the read-only escalation
74
- * surface only (commitRepairEscalations) — absence-means-not-askable is
75
- * safe there because it self-heals the moment visibility returns.
76
- * repair-provenance itself does NOT use this: it reads the full store and
77
- * may legitimately list (dry-run) or leave queued (apply) an entry this
78
- * predicate would call not-live, so the escalation surface and the CLI can
79
- * disagree about the same entry — see deadRewrites and resolvedRewriteIds
80
- * for the CLI's own, looser-on-purpose reasoning. */
59
+ /** Queued entries that are still answerable: the decision exists, still cites `from`, and is live. */
81
60
  export function liveRewrites(queued, decisions) {
82
61
  const byId = new Map(decisions.map((d) => [d.id, d]));
83
62
  return queued.filter((r) => {
@@ -85,128 +64,67 @@ export function liveRewrites(queued, decisions) {
85
64
  return !!d && d.commit === r.from && d.status !== "superseded" && d.status !== "rejected";
86
65
  });
87
66
  }
88
- /** Entries a decision has demonstrably outgrown: the decision IS present in
89
- * `decisions`, and it either moved on from `from` or has since been
90
- * superseded/rejected. An id this list can't see is deliberately NOT
91
- * included — absence is a property of the READER (a branch checkout that
92
- * predates the decision, an unmounted private overlay), not proof the match
93
- * is stale. This predicate backs a DESTRUCTIVE prune (repair-provenance
94
- * deletes what it returns from the queue file); repairqueue.ts's own
95
- * docstring is why that matters — the queue is the one durable record of a
96
- * match once ORIG_HEAD moves on and the matched-away commit can be gc'd, so
97
- * deleting an entry on a merely-absent id would destroy it unrecoverably.
98
- * Contrast liveRewrites, whose absence-means-not-askable rule is safe on
99
- * the read-only escalation surface, which self-heals once visibility
100
- * returns.
101
- *
102
- * Caveat: "demonstrably" is only as strong as this run's view of
103
- * `decisions`, which comes from the same git-tracked, branch-dependent
104
- * files that motivate treating ABSENCE as inconclusive. A decision whose
105
- * `commit`/`status` genuinely differ between branches (e.g. a repair
106
- * already applied and committed on one branch, checked out here from a
107
- * branch that predates it) could still read as "moved on" when it hasn't,
108
- * from this branch's perspective. Far narrower than the absence case this
109
- * function exists to fix, and not addressed here. */
67
+ /**
68
+ * Queued entries whose decision IS visible but moved on (different or null
69
+ * commit) or was superseded/rejected. An id absent from `decisions` is never
70
+ * dead: absence comes from the reader's view (a branch checkout, an unmounted
71
+ * private overlay) and the CLI deletes whatever this returns. Caveat: a decision
72
+ * that differs between branches could still read as "moved on".
73
+ */
110
74
  export function deadRewrites(queued, decisions) {
111
75
  const byId = new Map(decisions.map((d) => [d.id, d]));
112
76
  return queued.filter((r) => {
113
77
  const d = byId.get(r.id);
114
- return !!d && (d.commit !== r.from || d.status === "superseded" || d.status === "rejected");
78
+ if (!d)
79
+ return false;
80
+ return d.commit !== r.from || d.status === "superseded" || d.status === "rejected";
115
81
  });
116
82
  }
117
- /** Among `toApply` (the queued/matched candidates this run is about to act
118
- * on), the ids repair-provenance may treat as RESOLVED once it's done: the
119
- * decision is present in `decisions` this run, whether or not
120
- * repairDecisionCommit actually changed it (a present decision that already
121
- * moved on is "resolved" too — repairDecisionCommit's own bail refused it,
122
- * and there is nothing more this run can do about it). An id whose decision
123
- * is NOT present is never resolved — same reasoning as deadRewrites:
124
- * absence is the reader's transient view, not proof the match is settled,
125
- * so it must stay queued for a run where the decision is visible again. */
83
+ /** Ids of `toApply` whose decision is visible, whether or not the rewrite changed it. */
126
84
  export function resolvedRewriteIds(toApply, decisions) {
127
- const seen = new Set(decisions.map((d) => d.id));
128
- return new Set(toApply.filter((r) => seen.has(r.id)).map((r) => r.id));
85
+ const visible = new Set(decisions.map((d) => d.id));
86
+ return new Set(toApply.filter((r) => visible.has(r.id)).map((r) => r.id));
129
87
  }
130
- /** Remove any match whose exact {id, from, to} triple was already tombstoned
131
- * by an earlier `--drop` — otherwise a later merge that re-derives the
132
- * identical candidate would re-queue exactly what the human rejected. A
133
- * match sharing {id, from} but proposing a different `to` is unaffected: it
134
- * is a different proposal, not the rejected one.
135
- *
136
- * Preserves object identity for every surviving entry (a plain filter, never
137
- * a clone or a rebuild) — repair-provenance's own action relies on this to
138
- * diff its swept-vs-active queue by reference (`queue.filter(r =>
139
- * !active.includes(r))`) rather than re-spelling the {id, from, to} key a
140
- * third time. Do not change this to normalize or reconstruct entries
141
- * without updating that call site too. */
88
+ const tripleKey = (r) => JSON.stringify([r.id, r.from, r.to]);
89
+ /** Drop rewrites whose exact {id, from, to} triple is tombstoned. A plain filter: survivors keep identity. */
142
90
  export function withoutDropped(rewrites, dropped) {
143
- if (!dropped.length)
91
+ if (dropped.length === 0)
144
92
  return [...rewrites];
145
- const tombstoned = new Set(dropped.map((t) => `${t.id}\0${t.from}\0${t.to}`));
146
- return rewrites.filter((r) => !tombstoned.has(`${r.id}\0${r.from}\0${r.to}`));
93
+ const keys = new Set(dropped.map(tripleKey));
94
+ return rewrites.filter((r) => !keys.has(tripleKey(r)));
147
95
  }
148
- /** Append newly-dropped {id, from, to} triples to the tombstone list,
149
- * deduped — same append-only, dedupe-by-key shape as mergeRewrites. */
96
+ /** `existing` followed by the entries of `newly` whose exact triple is not already tombstoned. */
150
97
  export function addDropped(newly, existing) {
151
- const seen = new Set(existing.map((t) => `${t.id}\0${t.from}\0${t.to}`));
152
- const additions = newly.filter((t) => !seen.has(`${t.id}\0${t.from}\0${t.to}`));
153
- return [...existing, ...additions];
98
+ const keys = new Set(existing.map(tripleKey));
99
+ const out = [...existing];
100
+ for (const n of newly) {
101
+ const k = tripleKey(n);
102
+ if (keys.has(k))
103
+ continue;
104
+ keys.add(k);
105
+ out.push(n);
106
+ }
107
+ return out;
154
108
  }
155
- /** Combine a freshly-computed plan's rewrites with anything already queued
156
- * (src/core/repairqueue.ts) from an earlier detection. A fresh match —
157
- * computed just now, against the current range — overrides a queued one for
158
- * the same decision; queued entries no longer reachable are simply not
159
- * repeated by the fresh scan.
160
- *
161
- * Overrides by object identity, targeting exactly the one queued entry
162
- * firstFor (id) would pick — the same first-match rule --drop applies, and
163
- * the same identity-not-id targeting the dead-entry prune applies (#53; the
164
- * prune itself removes every provably-dead entry, not just a first match).
165
- * A corrupted queue file, a hand edit, or a bug upstream could otherwise
166
- * carry a SECOND entry sharing that id; overriding by id alone would
167
- * silently destroy that still-queued sibling too, with no tombstone and no
168
- * way to recover it, even though it was never the entry that would have
169
- * been applied. Any such sibling is left queued and untouched.
170
- *
171
- * How a survivor resolves: the dead-entry prune (deadRewrites), once
172
- * --apply has moved the decision past the sibling's `from`; or a --drop
173
- * whose tombstone matches the sibling's exact {id, from, to} — the same
174
- * --drop that dropped the fresh entry, when the sibling happens to share
175
- * its triple (the drop's own re-sweep, withoutDropped, catches it too), a
176
- * second explicit --drop otherwise. NOT a later fresh detection: by this
177
- * same first-match rule it always overrides the fresh entry sitting ahead
178
- * of it, never the sibling. */
109
+ /**
110
+ * Fresh detections first, then the queued entries they did not override. A fresh
111
+ * entry overrides only the FIRST queued entry with its id (see `firstFor`);
112
+ * later duplicate-id siblings survive untouched.
113
+ */
179
114
  export function mergeRewrites(fresh, queued) {
180
115
  const overridden = new Set();
181
- for (const r of fresh) {
182
- const first = firstFor(queued, r.id);
183
- if (first)
184
- overridden.add(first);
116
+ for (const f of fresh) {
117
+ const hit = firstFor(queued, f.id);
118
+ if (hit)
119
+ overridden.add(hit);
185
120
  }
186
- return [...fresh, ...queued.filter((r) => !overridden.has(r))];
121
+ return [...fresh, ...queued.filter((q) => !overridden.has(q))];
187
122
  }
188
- /** Partition the entries --apply is about to act on by whether their proposed
189
- * `to` commit actually exists in this repository. `existing` is
190
- * `commitsExist`'s result over every `to` in `toApply`: `null` means the
191
- * check itself failed to run (not a git repo, git missing, timeout) and is
192
- * treated as fail-open, same discipline as drift.ts's own use of
193
- * commitsExist — nothing is withheld just because the check couldn't run.
194
- *
195
- * A corrupted queue file, a hand-edited entry, or a bug upstream could
196
- * otherwise get a `to` that never resolves to a real commit written
197
- * straight into a decision's `commit` field and provenance evidence, then
198
- * auto-committed into shared team memory. `withheld` entries must stay
199
- * queued (never deleted, never passed to repairDecisionCommit) — the queue
200
- * file is the one durable record of the match, same reasoning as
201
- * deadRewrites/resolvedRewriteIds.
202
- *
203
- * Both returned arrays preserve object identity (never clones or rebuilds
204
- * an entry) — the CLI's queue sweep and its "still queued" reporting both
205
- * key a `Set` off the exact objects in `withheld`, by reference, precisely
206
- * because a corrupted queue file can carry two entries sharing an id (one
207
- * resolvable, one not): an id alone can't tell them apart. Do not change
208
- * this to normalize or reconstruct entries without updating those call
209
- * sites too — same caveat withoutDropped's own docstring carries. */
123
+ /**
124
+ * Split entries by whether their proposed `to` resolves here. `existing === null`
125
+ * means the check could not run, so it fails open (everything applicable).
126
+ * Order and object references are preserved.
127
+ */
210
128
  export function withheldForUnresolvableTo(toApply, existing) {
211
129
  if (existing === null)
212
130
  return { applicable: [...toApply], withheld: [] };
@@ -216,40 +134,31 @@ export function withheldForUnresolvableTo(toApply, existing) {
216
134
  (existing.has(r.to) ? applicable : withheld).push(r);
217
135
  return { applicable, withheld };
218
136
  }
219
- /** The one rule for which entry wins when more than one shares a decision id
220
- * (a corrupted queue file, a hand edit, or a bug upstream): first match, by
221
- * construction. Every caller goes through here so no two can independently
222
- * drift on which entry "won" — this file's own duplicate-id queue handling
223
- * has drifted between call sites three times already (see withoutDropped's,
224
- * withheldForUnresolvableTo's, and mergeRewrites' own docstrings). */
137
+ /** The single first-match rule for duplicate decision ids. */
225
138
  export function firstFor(entries, id) {
226
- return entries.find((r) => r.id === id);
139
+ return entries.find((e) => e.id === id);
227
140
  }
228
- /** pickRewrite is firstFor scoped to a plan's own rewrites (planCommitRepair
229
- * itself never produces same-id duplicates, but a plan carrying them isn't
230
- * ruled out here — see firstFor). repairDecisionCommit uses this to decide
231
- * what to WRITE; a caller that needs to know which entry WOULD be picked
232
- * without writing anything (the CLI's --apply summary and queue sweep)
233
- * calls this same function, so the two can never independently drift on
234
- * which entry "won". */
141
+ /** The plan's rewrite for `id` (first match). */
235
142
  export function pickRewrite(plan, id) {
236
143
  return firstFor(plan.rewrites, id);
237
144
  }
238
- /** Pure: rewrite `commit` for one decision, and its matching `commit:<sha>`
239
- * evidence entry too if one is present (replaceExact is a no-op when the
240
- * evidence array never cited the old sha) — or return the same reference
241
- * when the plan doesn't touch it. If the record moved on since the plan was
242
- * built (its commit no longer equals the plan's `from`), bail entirely
243
- * rather than applying a now-stale match. */
145
+ /**
146
+ * Pure: apply the plan's rewrite to a decision. Returns the same reference when
147
+ * there is no entry or the record moved on (`commit` no longer equals `from`);
148
+ * otherwise a copy with `commit` and the `commit:<from>` evidence moved to `to`
149
+ * (deduped if `to` was already cited).
150
+ */
244
151
  export function repairDecisionCommit(d, plan) {
245
- const mine = pickRewrite(plan, d.id);
246
- if (!mine || d.commit !== mine.from)
152
+ const entry = pickRewrite(plan, d.id);
153
+ if (!entry || d.commit !== entry.from)
247
154
  return d;
248
- const evidence = replaceExact(d.provenance.evidence, `commit:${mine.from}`, `commit:${mine.to}`);
249
155
  return {
250
156
  ...d,
251
- commit: mine.to,
252
- provenance: { ...d.provenance, evidence: evidence.values },
157
+ commit: entry.to,
158
+ provenance: {
159
+ ...d.provenance,
160
+ evidence: replaceExact(d.provenance.evidence, `commit:${entry.from}`, `commit:${entry.to}`).values,
161
+ },
253
162
  };
254
163
  }
255
164
  //# sourceMappingURL=commitrepair.js.map
@@ -28,6 +28,12 @@ export interface HunchConfig {
28
28
  * (see src/mcp/toolset.ts). Undefined = decide from the root's contents. */
29
29
  mcp_tools?: string;
30
30
  workspaces?: Partial<WorkspacesConfig>;
31
+ /** `hunch verify` knobs: `tests` discovery glob, `command` runner template
32
+ * (`{files}` is replaced by the selected test paths). */
33
+ verify?: {
34
+ tests?: string;
35
+ command?: string;
36
+ };
31
37
  }
32
38
  /** The effective workspace config: every field present, unknown values ignored. */
33
39
  export declare function workspacesConfig(config: HunchConfig): WorkspacesConfig;
@@ -35,6 +35,7 @@ export function readConfig(paths) {
35
35
  firmness: isFirmness(raw.firmness) ? raw.firmness : DEFAULT_FIRMNESS,
36
36
  ...(typeof raw.mcp_tools === "string" && raw.mcp_tools.trim() ? { mcp_tools: raw.mcp_tools.trim() } : {}),
37
37
  ...(raw.workspaces && typeof raw.workspaces === "object" && !Array.isArray(raw.workspaces) ? { workspaces: raw.workspaces } : {}),
38
+ ...(raw.verify && typeof raw.verify === "object" && !Array.isArray(raw.verify) ? { verify: raw.verify } : {}),
38
39
  };
39
40
  }
40
41
  catch {
@@ -57,13 +57,12 @@ export const TESTIMONY_CORRECTION_RATIONALE = "Recorded by the agent as a correc
57
57
  * Returns "" when the hint cannot be made repo-relative (no root, or a path outside the
58
58
  * repo). The caller then falls back to "**", where the existing severity guard
59
59
  * down-ranks a non-explicit blocking rule to a warning — fail-safe and honest, rather
60
- * than a blocking rule enforced nowhere.
60
+ * than a blocking rule that is enforced nowhere.
61
61
  *
62
- * Thin adapter over the shared `repoRelativeTarget` (core/paths.ts): that function
63
- * passes an unresolvable absolute hint through UNCHANGED (still absolute) rather
64
- * than signaling failure directly, since other callers (checkConstraints, why())
65
- * want the original target back to fail their own match safely. This adapter
66
- * converts that "still absolute" signal to "" — the fail-safe this caller needs. */
62
+ * This is a thin adapter over the shared `repoRelativeTarget`, which hands an
63
+ * unresolvable absolute hint back unchanged instead of signalling failure (other
64
+ * callers want the original target back). Here a result that is still absolute is
65
+ * turned into the empty string. */
67
66
  function repoRelativeHint(rawHint, root) {
68
67
  if (!rawHint)
69
68
  return "";
@@ -72,9 +71,10 @@ function repoRelativeHint(rawHint, root) {
72
71
  return rawHint;
73
72
  if (!root)
74
73
  return "";
75
- const rewritten = repoRelativeTarget(rawHint, root);
76
- const stillAbsolute = isAbsolute(rewritten) || /^[a-zA-Z]:/.test(rewritten);
77
- return stillAbsolute ? "" : rewritten;
74
+ const rel = repoRelativeTarget(rawHint, root);
75
+ if (isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
76
+ return "";
77
+ return rel;
78
78
  }
79
79
  /**
80
80
  * Build the Constraint a correction mints. Pure (caller passes `now`), so the
@@ -128,7 +128,12 @@ export declare function lexicalTokens(value: string): Set<string>;
128
128
  export declare function resetCommitReachabilityMemo(): void;
129
129
  /** Test hook: number of git spawns issued by delivery provenance probes so far. */
130
130
  export declare function commitReachabilityProbeCount(): number;
131
- export declare function localCommitReachability(root: string): (commit: string) => CommitReachability;
131
+ /** A per-build reachability probe. `prime` registers the commits the build may
132
+ * ask about; the first lookup then judges all of them in two batched spawns. */
133
+ export type LocalCommitReachability = ((commit: string) => CommitReachability) & {
134
+ prime: (commits: readonly string[]) => void;
135
+ };
136
+ export declare function localCommitReachability(root: string): LocalCommitReachability;
132
137
  /** Validate the public receipt without trusting a caller-supplied identity. */
133
138
  export declare function assertDeliveryEnvelope(envelope: DeliveryEnvelope): void;
134
139
  /** The string a hash-based injection dedup (`injectionMode`'s `hashInput`) must
@@ -312,12 +312,14 @@ function componentResolves(id, target, options) {
312
312
  return !!component && component.paths.some((path) => anchorResolves(path, target, options));
313
313
  }
314
314
  let gitProbeCount = 0;
315
- function gitProbe(root, args) {
315
+ function gitProbe(root, args, input) {
316
316
  gitProbeCount++;
317
317
  const result = spawnSync("git", ["-C", root, ...args], {
318
318
  encoding: "utf8",
319
319
  env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" },
320
- stdio: ["ignore", "pipe", "ignore"],
320
+ input,
321
+ maxBuffer: 64 * 1024 * 1024,
322
+ stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"],
321
323
  timeout: 2_000,
322
324
  windowsHide: true,
323
325
  });
@@ -363,7 +365,60 @@ export function localCommitReachability(root) {
363
365
  let initialized = false;
364
366
  let branch = null;
365
367
  let tip = null;
366
- return (commit) => {
368
+ // #485: each hook call is a fresh process, so the memo alone still cost two
369
+ // spawns per capture commit. Primed commits are judged together on the first
370
+ // lookup — one `cat-file --batch-check` resolves them, one `rev-list` tests
371
+ // ancestry — and kept for this build only. A spawn failure (timeout, ENOBUFS)
372
+ // primes every pending commit "unknown", the verdict a failed per-commit probe
373
+ // gives, so a slow git costs one timeout rather than 2N more spawns. Unexpected
374
+ // output leaves a commit unprimed and the per-commit path below decides it, so
375
+ // within a build the verdicts match that path.
376
+ let expected = [];
377
+ const primed = new Map();
378
+ const batch = (commits) => {
379
+ const pending = [...new Set(commits)].filter((commit) => /^[0-9a-f]{7,64}$/i.test(commit) && !primed.has(commit) && !(tip && reachabilityMemo.has(`${root}\0${tip}\0${commit}`)));
380
+ if (!pending.length || !branch)
381
+ return;
382
+ const resolved = gitProbe(root, ["cat-file", "--batch-check=%(objectname)"], pending.map((commit) => `${commit}^{commit}\n`).join(""));
383
+ if (!resolved.available) {
384
+ for (const commit of pending)
385
+ primed.set(commit, "unknown");
386
+ return;
387
+ }
388
+ const lines = resolved.stdout.split(/\r?\n/);
389
+ if (resolved.status !== 0 || lines.length !== pending.length)
390
+ return;
391
+ const shas = new Map();
392
+ for (const [i, commit] of pending.entries()) {
393
+ const line = lines[i];
394
+ if (/^[0-9a-f]{40,64}$/i.test(line))
395
+ shas.set(commit, line);
396
+ // A missing object is not memoized, as on the per-commit path.
397
+ else if (line === `${commit}^{commit} missing` || line === `${commit}^{commit} ambiguous`)
398
+ primed.set(commit, "unreachable");
399
+ else
400
+ return;
401
+ }
402
+ if (!shas.size)
403
+ return;
404
+ // `rev-list <shas> ^<tip>` lists exactly the commits NOT reachable from the tip.
405
+ // It prints the whole history behind an unreachable capture commit (e.g.
406
+ // orphaned by a rewrite), so this probe costs O(|unreachable history|); a
407
+ // history big enough to time out primes "unknown" below.
408
+ const listed = gitProbe(root, ["rev-list", "--stdin"], `${[...new Set(shas.values())].join("\n")}\n^${tip ?? branch}\n`);
409
+ // A failed spawn or a nonzero exit is a merge-base answer outside 0/1: "unknown", never memoized.
410
+ if (!listed.available || listed.status !== 0) {
411
+ for (const commit of shas.keys())
412
+ primed.set(commit, "unknown");
413
+ return;
414
+ }
415
+ const unreachable = new Set(listed.stdout.split(/\r?\n/).filter(Boolean));
416
+ for (const [commit, sha] of shas) {
417
+ const verdict = unreachable.has(sha) ? "unreachable" : "reachable";
418
+ primed.set(commit, tip ? memoizeReachability(`${root}\0${tip}\0${commit}`, verdict) : verdict);
419
+ }
420
+ };
421
+ const lookup = (commit) => {
367
422
  if (!initialized) {
368
423
  // The default ref is looked up every build (origin/HEAD can be retargeted
369
424
  // while the old ref still exists); only verdicts are shared across builds.
@@ -388,6 +443,14 @@ export function localCommitReachability(root) {
388
443
  const memo = key ? reachabilityMemo.get(key) : undefined;
389
444
  if (memo)
390
445
  return memo;
446
+ if (expected.length && !primed.has(commit)) {
447
+ const commits = [...expected, commit];
448
+ expected = [];
449
+ batch(commits);
450
+ }
451
+ const batched = primed.get(commit);
452
+ if (batched)
453
+ return batched;
391
454
  const remember = (verdict) => key ? memoizeReachability(key, verdict) : verdict;
392
455
  const exact = gitProbe(root, ["rev-parse", "--verify", "--quiet", `${commit}^{commit}`]);
393
456
  if (!exact.available)
@@ -404,6 +467,7 @@ export function localCommitReachability(root) {
404
467
  return remember("unreachable");
405
468
  return "unknown";
406
469
  };
470
+ return Object.assign(lookup, { prime: (commits) => { expected.push(...commits); } });
407
471
  }
408
472
  function validationState(anchors, anchorsResolve, commit, reachability, requireCommit = false, canValidateAnchors = true) {
409
473
  if (anchors.length && canValidateAnchors && !anchors.some(anchorsResolve)) {
@@ -563,8 +627,10 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
563
627
  const profile = options.profile ?? "builder";
564
628
  const budget = Number.isFinite(ctx.budget_tokens) ? Math.max(0, Math.floor(ctx.budget_tokens)) : 1500;
565
629
  const cap = budget * 4;
566
- const reachability = options.commitReachability
567
- ?? (options.root ? localCommitReachability(options.root) : (() => "unknown"));
630
+ const local = !options.commitReachability && options.root ? localCommitReachability(options.root) : null;
631
+ // Only decisions carry a capture commit; register them so the first lookup batches them all (#485).
632
+ local?.prime(ctx.decisions.flatMap((decision) => decision.commit ? [decision.commit] : []));
633
+ const reachability = options.commitReachability ?? local ?? (() => "unknown");
568
634
  const canValidateAnchors = options.root !== undefined || options.symbols !== undefined || options.components !== undefined;
569
635
  const candidates = [];
570
636
  const query = queryProfile(ctx.target, options);
@@ -360,11 +360,11 @@ function htmlBlockEnd(s) {
360
360
  /** Character ranges covered by inline code spans (`…`), same rationale as
361
361
  * codeRanges: prose quoting a marker in backticks is showing an example.
362
362
  * CommonMark-lite: an opener run pairs with the next run of the SAME length
363
- * on the same line; unpaired runs never open a span.
364
- * Expects LF-normalized text — see parseDocAnchors's normalization.
365
- * `split("\n")` does not split a bare CR, so on CR-only input the whole
366
- * document reads as one line and stray backticks on different lines
367
- * falsely pair. */
363
+ * on the same line; an unpaired backtick run never opens a span, and pairing
364
+ * is per line. Expects LF-normalized input (see scanDocAnchors): the text is
365
+ * split on LF only, so a CR-only document would read as one long line and
366
+ * stray backticks on different lines would falsely pair.
367
+ */
368
368
  function inlineSpanRanges(text) {
369
369
  const ranges = [];
370
370
  let offset = 0;
@@ -394,10 +394,10 @@ export function parseDocAnchors(text) {
394
394
  * never closes hides every marker below it, almost always by mistake (#381). */
395
395
  export function scanDocAnchors(text) {
396
396
  // codeRanges is CRLF-sensitive (its fence-line regex's `.` excludes \r, so
397
- // "```\r" never matched at all on a CRLF checkout); inlineSpanRanges is
398
- // lone-CR-sensitive (split("\n") doesn't split a bare CR — see its
399
- // docblock). Either way an example marker inside a fence/span registered as
400
- // a live, pinned anchor. Normalizing both CRLF and lone CR once here keeps
397
+ // trailing CR means a CRLF fence line was never recognized as a fence), and the
398
+ // inline-span scan is sensitive to a bare CR (it splits on LF only, see its
399
+ // docblock). Either way an example marker inside a fence or span got registered
400
+ // as a live pinned anchor. Normalizing CRLF and lone CR once, here, keeps
401
401
  // codeRanges/inlineSpanRanges/MARKER offsets consistent with each other
402
402
  // and with the line numbers reported below. A leading BOM is dropped too, so
403
403
  // line 1 starts at column 0 (#381); it is one char on line 1, so line numbers hold.
@@ -48,25 +48,17 @@ export function computeDrift(store, root, deps = {}) {
48
48
  return liveFiles.has(p) || liveDirs.some((dir) => p.startsWith(dir));
49
49
  };
50
50
  const premiseEnv = { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) };
51
- // A plain existence check is meaningless outside a git repo — computed once,
52
- // not per decision, and never a false positive for a directory that merely
53
- // happens to hold .hunch/ without being a git checkout. Injectable so tests
54
- // (and callers with a cheaper oracle) never have to shell out per decision.
55
- const gitRepo = isGitRepo(root);
56
- // One batched `git cat-file --batch-check` for every commit-bearing decision,
57
- // not one `rev-parse` per decision — skipped entirely when a predicate is
58
- // injected (tests/callers with a cheaper oracle never pay for this at all).
59
- const resolvableCommits = gitRepo && !deps.commitResolvable
60
- ? commitsExist([...new Set(decisions.filter((d) => d.commit).map((d) => d.commit))], root)
61
- : null;
62
- const defaultCommitResolvable = (sha) => {
63
- if (!gitRepo)
64
- return true; // never flag outside a git repo
65
- if (resolvableCommits === null)
66
- return true; // the batch check itself failed, or was skipped — fail open
67
- return resolvableCommits.has(sha);
68
- };
69
- const commitResolvable = deps.commitResolvable ?? defaultCommitResolvable;
51
+ // Commit-provenance resolver. Injectable so tests/callers avoid spawning git. The
52
+ // default runs ONE batched existence check over every decision's commit (not a
53
+ // process per decision), and outside a git repo the check is meaningless, so it
54
+ // never flags anything there.
55
+ let commitResolvable = deps.commitResolvable;
56
+ if (!commitResolvable) {
57
+ const inRepo = isGitRepo(root);
58
+ const shas = inRepo ? [...new Set(decisions.map((d) => d.commit).filter((c) => !!c))] : [];
59
+ const resolvable = inRepo ? commitsExist(shas, root) : null;
60
+ commitResolvable = (sha) => !inRepo || resolvable === null || resolvable.has(sha);
61
+ }
70
62
  for (const d of decisions) {
71
63
  // 1. DEAD-REFERENCE — only for in-force decisions; a superseded one referencing
72
64
  // a since-deleted file is legitimate history, not drift.
@@ -79,15 +71,18 @@ export function computeDrift(store, root, deps = {}) {
79
71
  findings.push({ kind: "dead-ref", id: d.id, detail: `references missing file "${f}"` });
80
72
  }
81
73
  }
82
- // 8. COMMIT-UNRESOLVABLE — an in-force decision's commit provenance no longer
83
- // resolves at all (source branch gone + gc'd past recovery). The
84
- // opportunistic post-merge repair (commitrepair.ts) is the fix path; this
85
- // is purely the "nothing caught it" signal — deterministic, never auto-fixed.
86
- // Mirrors repair.ts:59's live-records-only rule: `inForce` alone doesn't
87
- // exclude a never-adopted "rejected" decision (superseded_by is null for
88
- // those too), so exclude it explicitly here.
74
+ // 8. COMMIT-UNRESOLVABLE — the commit provenance no longer resolves here (its
75
+ // source branch was deleted and garbage-collected). The post-merge repair
76
+ // flow is the fix path; this is only the deterministic "nothing caught it"
77
+ // signal and is never auto-fixed. Same live-records-only rule as the repair
78
+ // module, hence the explicit rejected exclusion (a never-adopted decision
79
+ // also has superseded_by null).
89
80
  if (d.commit && d.status !== "rejected" && !commitResolvable(d.commit)) {
90
- findings.push({ kind: "commit-unresolvable", id: d.id, detail: `commit ${d.commit} no longer resolves in this repository — provenance may need manual repair` });
81
+ findings.push({
82
+ kind: "commit-unresolvable",
83
+ id: d.id,
84
+ detail: `commit ${d.commit} no longer resolves in this repository — provenance may need manual repair`,
85
+ });
91
86
  }
92
87
  }
93
88
  // 2. SUPERSEDE-INTEGRITY — a contradiction class: A.supersedes = B, but B is