@davesheffer/hunch 1.46.0 → 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 (56) hide show
  1. package/dist/cli/index.js +151 -321
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/constitution/delta.js +2 -2
  5. package/dist/constitution/sourceMutation.js +28 -18
  6. package/dist/core/commitrepair.d.ts +43 -138
  7. package/dist/core/commitrepair.js +76 -167
  8. package/dist/core/correction.js +9 -9
  9. package/dist/core/docanchors.js +9 -9
  10. package/dist/core/drift.js +22 -27
  11. package/dist/core/escalations.d.ts +16 -58
  12. package/dist/core/escalations.js +39 -121
  13. package/dist/core/glob.d.ts +7 -7
  14. package/dist/core/glob.js +12 -11
  15. package/dist/core/groundingLag.d.ts +2 -9
  16. package/dist/core/groundingLag.js +5 -11
  17. package/dist/core/groundingMerge.d.ts +2 -7
  18. package/dist/core/groundingMerge.js +35 -54
  19. package/dist/core/paths.d.ts +17 -24
  20. package/dist/core/paths.js +37 -43
  21. package/dist/core/refrepair.d.ts +2 -1
  22. package/dist/core/refrepair.js +4 -4
  23. package/dist/core/repairqueue.d.ts +17 -34
  24. package/dist/core/repairqueue.js +48 -98
  25. package/dist/core/types.d.ts +3 -3
  26. package/dist/core/types.js +1 -1
  27. package/dist/extractors/diff.d.ts +4 -4
  28. package/dist/extractors/diff.js +25 -29
  29. package/dist/extractors/git.d.ts +8 -23
  30. package/dist/extractors/git.js +45 -64
  31. package/dist/extractors/helm.d.ts +10 -0
  32. package/dist/extractors/helm.js +43 -59
  33. package/dist/extractors/indexer.d.ts +6 -0
  34. package/dist/extractors/indexer.js +126 -178
  35. package/dist/extractors/k8sManifest.d.ts +23 -80
  36. package/dist/extractors/k8sManifest.js +319 -690
  37. package/dist/extractors/languages.d.ts +28 -37
  38. package/dist/extractors/languages.js +55 -54
  39. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  40. package/dist/extractors/nativeTreeSitter.js +17 -14
  41. package/dist/extractors/parse.d.ts +7 -13
  42. package/dist/extractors/parse.js +39 -51
  43. package/dist/integrations/claudemd.js +1 -0
  44. package/dist/integrations/gitignore.js +3 -5
  45. package/dist/integrations/hooks.d.ts +9 -14
  46. package/dist/integrations/hooks.js +19 -28
  47. package/dist/integrations/mergeDriver.js +13 -14
  48. package/dist/mcp/server.d.ts +4 -5
  49. package/dist/mcp/server.js +60 -73
  50. package/dist/store/hunchStore.js +12 -18
  51. package/dist/synthesis/provider.d.ts +19 -21
  52. package/dist/synthesis/provider.js +67 -76
  53. package/dist/synthesis/synthesize.d.ts +8 -8
  54. package/dist/synthesis/synthesize.js +32 -49
  55. package/package.json +2 -2
  56. 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
@@ -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
@@ -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
@@ -33,76 +33,34 @@ export interface Escalation {
33
33
  detail: string;
34
34
  /** the concrete resolution the human's answer maps to. */
35
35
  resolution: string;
36
- /** false ONLY for an entry whose own resolution requires acting on a
37
- * DIFFERENT entry first (a duplicate-id queue's non-drop, non-apply
38
- * follower) — it still surfaces, for transparency, but gating surfaces
39
- * (CI exit code, "needs your call" tallies) must not count it: resolving
40
- * the actionable entry ahead of it is what clears it, not anything a
41
- * human can do to this row directly (#61). Omitted (undefined) means
42
- * true — every other escalation kind is always directly actionable. */
36
+ /** `false` marks an entry whose resolution depends on first acting on a DIFFERENT
37
+ * entry: the non-drop, non-apply follower in a duplicate-id commit-repair queue.
38
+ * It still surfaces for transparency, but gating surfaces (the CI exit code and
39
+ * "needs your call" tallies) must not count it. Omitted/undefined = actionable. */
43
40
  actionable?: boolean;
44
41
  }
45
- /** Escalations gating surfaces (CI exit codes, "needs your call" counts/lists)
46
- * should act on: everything except an entry explicitly marked
47
- * actionable:false (#61). The full list — including non-actionable
48
- * entries — still belongs in any transparency-facing output (e.g. --json). */
42
+ /** The entries a human can act on right now (order preserved). */
49
43
  export declare function actionableEscalations(items: readonly Escalation[]): Escalation[];
50
44
  export interface EscalationSummary {
51
- /** the subset a gating surface should count/exit/ask on. */
45
+ /** the gating subset. */
52
46
  actionable: Escalation[];
53
- /** how many entries were filtered out — informational-only, shown for
54
- * transparency but not itself a decision to raise. */
47
+ /** how many entries were filtered out as context-only. */
55
48
  context: number;
56
49
  }
57
- /** One computation for every "N need your call (+M shown for context)"
58
- * consumer (CLI `escalations`, SessionStart, the `hunch_now`/`hunch_escalations`
59
- * MCP tools) — introduced alongside `actionable` (#61) specifically so a
60
- * future consumer of Escalation[] can't repeat the mistake of hand-rolling
61
- * the filter and forgetting it (the VS Code panel did, before this existed). */
62
50
  export declare function summarizeEscalations(items: readonly Escalation[]): EscalationSummary;
63
- /** "you" (the CLI, talking directly to the operator) vs "mcp" (an assistant
64
- * relaying a question to the human, third person). */
51
+ /** "cli" speaks to the operator (second person); "mcp" is an assistant relaying to the human (third person). */
65
52
  export type EscalationAudience = "cli" | "mcp";
66
- /** The ONE "N need your call (+M shown for context)" headline, shared by the
67
- * CLI `escalations` command and the `hunch_escalations` MCP tool (#61) —
68
- * including its own "nothing actionable, but N item(s) queued for context"
69
- * fallback, so that unreachable-today prose exists in exactly one place
70
- * instead of being hand-copied per consumer (the copy-paste is how the VS
71
- * Code panel missed the filter entirely before `actionable` existed).
72
- * Callers are expected to have already special-cased `items.length === 0`
73
- * (a plainer "nothing needs your decision" message, no tally at all). */
53
+ /** One-line heading for a non-empty escalation list. Callers handle the empty list themselves. */
74
54
  export declare function escalationHeadline(items: readonly Escalation[], audience: EscalationAudience): string;
75
55
  /** The decisions a human must make NOW, to be asked INLINE. Empty in a healthy graph. */
76
56
  export declare function pendingEscalations(decisions: readonly Decision[]): Escalation[];
77
- /** A commit-provenance repair the post-merge hook detected and queued
78
- * (src/core/repairqueue.ts) but never applied — the match signal (related_files
79
- * overlap) isn't strong enough to write unattended, so it's a question for the
80
- * human, exactly like every other entry here, never a silent write.
81
- *
82
- * `decisions` is the caller's VISIBLE scope (supplies the title only) —
83
- * MCP/SessionStart pass store.advisoryRecs(), which is public-only in
84
- * private mode. `live` is the scope liveRewrites checks the repair against;
85
- * it defaults to `decisions` but callers on an advisory scope must pass the
86
- * FULL store (store.recs()) here, since repair-provenance itself reads the
87
- * full store — an overlay decision's repair is fully answerable even where
88
- * its title isn't visible, and must not go silent just because the title is
89
- * private.
90
- *
91
- * `withheld` (from repairqueue.ts's withheldRewrites — a git-facing check
92
- * this module deliberately stays free of, so the caller runs it and passes
93
- * the result in) names entries whose proposed `to` doesn't resolve to a
94
- * real commit here. `--apply --only <id>` can never resolve one of these —
95
- * advertising it as the answer would be advice guaranteed to no-op
96
- * forever, which is worse than not asking at all. Such an entry still
97
- * needs a human (only `--drop` can retire it), so it still escalates, just
98
- * with wording that doesn't promise `--apply` will help.
99
- *
100
- * Keyed by OBJECT, not by id, matching withheldForUnresolvableTo's own
101
- * contract: a corrupted queue file can carry two entries sharing an id
102
- * (one resolvable, one not) — an id-keyed set couldn't tell them apart and
103
- * would wrongly tag the resolvable sibling as unresolvable too. Every
104
- * caller of this function already passes the SAME `queued` array to both
105
- * this function and withheldRewrites, so identity holds. */
57
+ /** A commit-provenance repair the post-merge hook queued but never applied is a human
58
+ * question, never a silent write. `decisions` is the caller's VISIBLE scope and supplies
59
+ * titles only. `live` is the scope liveness is checked against: callers on an advisory
60
+ * scope pass the full store so an overlay decision's repair isn't silenced just because
61
+ * its title is private. `withheld` holds entries whose proposed `to` doesn't resolve to a
62
+ * real commit; membership is by object identity, not id, so two corrupted entries
63
+ * sharing an id stay distinguishable. */
106
64
  export declare function commitRepairEscalations(queued: readonly CommitRewrite[], decisions: readonly Decision[], live?: readonly Decision[], withheld?: ReadonlySet<CommitRewrite>): Escalation[];
107
65
  /** The minimal policy shape the escalation scan needs — a structural subset of
108
66
  * constitution PolicySpec, so this module stays dependency-free of the