@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.
- package/dist/cli/index.js +151 -321
- package/dist/cli/invocation.d.ts +8 -13
- package/dist/cli/invocation.js +26 -29
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/correction.js +9 -9
- package/dist/core/docanchors.js +9 -9
- package/dist/core/drift.js +22 -27
- package/dist/core/escalations.d.ts +16 -58
- package/dist/core/escalations.js +39 -121
- package/dist/core/glob.d.ts +7 -7
- package/dist/core/glob.js +12 -11
- package/dist/core/groundingLag.d.ts +2 -9
- package/dist/core/groundingLag.js +5 -11
- package/dist/core/groundingMerge.d.ts +2 -7
- package/dist/core/groundingMerge.js +35 -54
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/extractors/diff.d.ts +4 -4
- package/dist/extractors/diff.js +25 -29
- package/dist/extractors/git.d.ts +8 -23
- package/dist/extractors/git.js +45 -64
- package/dist/extractors/helm.d.ts +10 -0
- package/dist/extractors/helm.js +43 -59
- package/dist/extractors/indexer.d.ts +6 -0
- package/dist/extractors/indexer.js +126 -178
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +28 -37
- package/dist/extractors/languages.js +55 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +7 -13
- package/dist/extractors/parse.js +39 -51
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/hooks.d.ts +9 -14
- package/dist/integrations/hooks.js +19 -28
- package/dist/integrations/mergeDriver.js +13 -14
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +60 -73
- package/dist/store/hunchStore.js +12 -18
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +67 -76
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +32 -49
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deterministic repair
|
|
2
|
+
* Deterministic repair of a decision's commit provenance after a squash-merge.
|
|
3
3
|
*
|
|
4
|
-
* A squash-merge
|
|
5
|
-
*
|
|
6
|
-
* This module matches
|
|
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
|
-
/**
|
|
21
|
-
function
|
|
22
|
-
return
|
|
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
|
|
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
|
|
59
|
-
if (
|
|
50
|
+
const scope = (d.related_files ?? []).filter(isExactScope);
|
|
51
|
+
if (scope.length === 0)
|
|
60
52
|
continue;
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
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
|
-
|
|
78
|
+
if (!d)
|
|
79
|
+
return false;
|
|
80
|
+
return d.commit !== r.from || d.status === "superseded" || d.status === "rejected";
|
|
115
81
|
});
|
|
116
82
|
}
|
|
117
|
-
/**
|
|
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
|
|
128
|
-
return new Set(toApply.filter((r) =>
|
|
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
|
-
|
|
131
|
-
|
|
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 (
|
|
91
|
+
if (dropped.length === 0)
|
|
144
92
|
return [...rewrites];
|
|
145
|
-
const
|
|
146
|
-
return rewrites.filter((r) => !
|
|
93
|
+
const keys = new Set(dropped.map(tripleKey));
|
|
94
|
+
return rewrites.filter((r) => !keys.has(tripleKey(r)));
|
|
147
95
|
}
|
|
148
|
-
/**
|
|
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
|
|
152
|
-
const
|
|
153
|
-
|
|
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
|
-
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
|
|
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
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
184
|
-
overridden.add(
|
|
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((
|
|
121
|
+
return [...fresh, ...queued.filter((q) => !overridden.has(q))];
|
|
187
122
|
}
|
|
188
|
-
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
|
|
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
|
|
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((
|
|
139
|
+
return entries.find((e) => e.id === id);
|
|
227
140
|
}
|
|
228
|
-
/**
|
|
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
|
-
/**
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
|
|
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
|
|
246
|
-
if (!
|
|
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:
|
|
252
|
-
provenance: {
|
|
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
|
package/dist/core/correction.js
CHANGED
|
@@ -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
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
package/dist/core/docanchors.js
CHANGED
|
@@ -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
|
|
364
|
-
* Expects LF-normalized
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
|
|
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
|
-
//
|
|
398
|
-
//
|
|
399
|
-
// docblock). Either way an example marker inside a fence
|
|
400
|
-
// a live
|
|
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.
|
package/dist/core/drift.js
CHANGED
|
@@ -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
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 —
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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({
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
45
|
+
/** the gating subset. */
|
|
52
46
|
actionable: Escalation[];
|
|
53
|
-
/** how many entries were filtered out
|
|
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
|
-
/** "
|
|
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
|
-
/**
|
|
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
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|