@davesheffer/hunch 1.10.4 → 1.10.6
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 +10 -2
- package/dist/constitution/g2BehaviorCandidates.js +34 -43
- package/dist/constitution/service.js +15 -5
- package/dist/core/topics.js +24 -2
- package/dist/mcp/server.js +3 -3
- package/dist/store/db.js +43 -5
- package/dist/store/hunchStore.js +36 -5
- package/dist/store/merge.js +40 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -3781,7 +3781,12 @@ program
|
|
|
3781
3781
|
// travel further than a terminal. Union view: `hunch now --private`.
|
|
3782
3782
|
const s = new HunchStore(paths);
|
|
3783
3783
|
try {
|
|
3784
|
-
|
|
3784
|
+
// Mode-aware: in unified ("shared") mode the public `.hunch/` is only a routing
|
|
3785
|
+
// shell, so loading it alone makes session-start orientation — recent work,
|
|
3786
|
+
// roadmap, escalations — report an empty graph for a repo whose memory is all
|
|
3787
|
+
// in the overlay. Private mode stays public-only: session transcripts travel
|
|
3788
|
+
// further than a terminal.
|
|
3789
|
+
const decisions = s.advisoryRecs("decisions");
|
|
3785
3790
|
const { recent, roadmap, pendingReview } = nowData(decisions, 3);
|
|
3786
3791
|
if (!decisions.length) {
|
|
3787
3792
|
// Fresh graph: nothing to orient on, but the operating loop still ships.
|
|
@@ -3912,7 +3917,10 @@ program
|
|
|
3912
3917
|
}
|
|
3913
3918
|
// Decision-grounding (§3): for topic-anchored decisions governing this file, state
|
|
3914
3919
|
// the current decision assertively (graph over any stale doc) + what it rejected.
|
|
3915
|
-
|
|
3920
|
+
// The FULL decision set is passed alongside the file slice so a topic contested
|
|
3921
|
+
// somewhere else in the graph is reported as unresolved instead of being asserted
|
|
3922
|
+
// as settled — the collision's two sides often live in different files.
|
|
3923
|
+
const grounding = renderGrounding(ctx.decisions, store.recs("decisions"));
|
|
3916
3924
|
if (grounding)
|
|
3917
3925
|
text += `\n\n${grounding}`;
|
|
3918
3926
|
if (docGround)
|
|
@@ -567,22 +567,27 @@ function runLeg(root, session, hooks, env, candidate, commit, expected, source,
|
|
|
567
567
|
const testFile = join(checkout, candidate.test.file);
|
|
568
568
|
mkdirSync(dirname(testFile), { recursive: true });
|
|
569
569
|
writeFileSync(testFile, source);
|
|
570
|
-
|
|
570
|
+
// ONE scoring mode, always reporter-based. The exit code of a `node --test` run is a
|
|
571
|
+
// property of the whole FILE, not of the selected test: a failure in an unrelated
|
|
572
|
+
// sibling flips it, so a proxy-grounded candidate could record behavior_confirmed (or
|
|
573
|
+
// its negation) from evidence that has nothing to do with the candidate. The reporter
|
|
574
|
+
// path already existed and is the unconditional standard in every other evidence
|
|
575
|
+
// surface here (behaviorEvaluator, g2Drills, g3Conformance) — this was the one holdout.
|
|
576
|
+
//
|
|
577
|
+
// The pattern is also derived from the candidate rather than trusting runner.argv:
|
|
578
|
+
// argv could carry a pattern selecting a DIFFERENT test than the one being attested,
|
|
579
|
+
// and an un-escaped raw name is a regex that can match siblings.
|
|
571
580
|
const reporter = join(run, "reporter.mjs");
|
|
572
|
-
const patternArg =
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
"--test-reporter-destination=stdout",
|
|
583
|
-
candidate.test.file,
|
|
584
|
-
]
|
|
585
|
-
: ["--test", patternArg, candidate.test.file];
|
|
581
|
+
const patternArg = `--test-name-pattern=${exactNodeTestPattern(candidate.test.name)}`;
|
|
582
|
+
writeFileSync(reporter, NODE_TEST_REPORTER_SOURCE);
|
|
583
|
+
const testArgs = [
|
|
584
|
+
"--test",
|
|
585
|
+
nodeTestIsolationFlag(),
|
|
586
|
+
patternArg,
|
|
587
|
+
`--test-reporter=${pathToFileURL(reporter).href}`,
|
|
588
|
+
"--test-reporter-destination=stdout",
|
|
589
|
+
candidate.test.file,
|
|
590
|
+
];
|
|
586
591
|
let args;
|
|
587
592
|
if (candidate.runner.kind === "node-test") {
|
|
588
593
|
args = testArgs;
|
|
@@ -614,36 +619,22 @@ function runLeg(root, session, hooks, env, candidate, commit, expected, source,
|
|
|
614
619
|
else {
|
|
615
620
|
const exitCode = result.status ?? null;
|
|
616
621
|
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
leg = { commit, expected, result: "failed", exit_code: exitCode, ...(dependencySnapshotId ? { dependency_snapshot_id: dependencySnapshotId } : {}) };
|
|
631
|
-
}
|
|
632
|
-
else {
|
|
633
|
-
leg = errorLeg(commit, expected, "runner-outcome-inconsistent", dependencySnapshotId);
|
|
634
|
-
}
|
|
622
|
+
const matches = nodeTestReporterEvents(result.stdout ?? "")
|
|
623
|
+
.filter((event) => event.name === candidate.test.name && !event.skip && !event.todo);
|
|
624
|
+
if (matches.length === 0) {
|
|
625
|
+
leg = errorLeg(commit, expected, nodeTestInfrastructureError(output) ?? "selected-test-not-executed", dependencySnapshotId);
|
|
626
|
+
}
|
|
627
|
+
else if (matches.length > 1) {
|
|
628
|
+
leg = errorLeg(commit, expected, "selected-test-ambiguous", dependencySnapshotId);
|
|
629
|
+
}
|
|
630
|
+
else if (matches[0].type === "test:pass") {
|
|
631
|
+
leg = { commit, expected, result: "passed", exit_code: exitCode, ...(dependencySnapshotId ? { dependency_snapshot_id: dependencySnapshotId } : {}) };
|
|
632
|
+
}
|
|
633
|
+
else if (matches[0].type === "test:fail") {
|
|
634
|
+
leg = { commit, expected, result: "failed", exit_code: exitCode, ...(dependencySnapshotId ? { dependency_snapshot_id: dependencySnapshotId } : {}) };
|
|
635
635
|
}
|
|
636
636
|
else {
|
|
637
|
-
|
|
638
|
-
leg = infrastructureError
|
|
639
|
-
? errorLeg(commit, expected, infrastructureError, dependencySnapshotId)
|
|
640
|
-
: {
|
|
641
|
-
commit,
|
|
642
|
-
expected,
|
|
643
|
-
result: exitCode === 0 ? "passed" : "failed",
|
|
644
|
-
exit_code: exitCode,
|
|
645
|
-
...(dependencySnapshotId ? { dependency_snapshot_id: dependencySnapshotId } : {}),
|
|
646
|
-
};
|
|
637
|
+
leg = errorLeg(commit, expected, "runner-outcome-inconsistent", dependencySnapshotId);
|
|
647
638
|
}
|
|
648
639
|
}
|
|
649
640
|
}
|
|
@@ -97,6 +97,19 @@ export function shadowCommitEligible(root, policy, commit) {
|
|
|
97
97
|
return false;
|
|
98
98
|
throw new Error(`cannot compare shadow commit ${commit} with policy introduction ${sourceCommit}: ${(check.stderr ?? "").trim() || `git exited ${check.status}`}`);
|
|
99
99
|
}
|
|
100
|
+
/** The commit a shadow receipt's ancestry check must be anchored to.
|
|
101
|
+
*
|
|
102
|
+
* A WORKSPACE receipt retains a content-addressed PSEUDO-head for dedupe — it is not a
|
|
103
|
+
* real git rev. Handing it to `git merge-base --is-ancestor` makes git exit with
|
|
104
|
+
* neither 0 nor 1, which shadowCommitEligible turns into a THROW, so the surface
|
|
105
|
+
* hard-fails instead of reporting. Ancestry is always the real base commit.
|
|
106
|
+
*
|
|
107
|
+
* Shared because that rule was previously written out at one call site (with this
|
|
108
|
+
* explanation attached) and silently omitted at another — the same one-side-only drift
|
|
109
|
+
* as OVERLAY_IGNORE. One definition, both callers. */
|
|
110
|
+
export function shadowAncestryCommit(record) {
|
|
111
|
+
return record.evaluation.repository.base ?? record.evaluation.repository.head;
|
|
112
|
+
}
|
|
100
113
|
export class ConstitutionService {
|
|
101
114
|
store;
|
|
102
115
|
root;
|
|
@@ -859,7 +872,7 @@ export class ConstitutionService {
|
|
|
859
872
|
const proof = proofs.find((candidate) => candidate.id === record.proof_id);
|
|
860
873
|
if (!proof || proof.policy_hash !== record.policy_hash)
|
|
861
874
|
return false;
|
|
862
|
-
if (!shadowCommitEligible(this.root, policy, record
|
|
875
|
+
if (!shadowCommitEligible(this.root, policy, shadowAncestryCommit(record)))
|
|
863
876
|
return false;
|
|
864
877
|
const composition = compositionDescendants(policy, policies);
|
|
865
878
|
return proof.policy_hash === policyProofHash(policy, composition);
|
|
@@ -1099,10 +1112,7 @@ export class ConstitutionService {
|
|
|
1099
1112
|
const audit = this.repository.listShadowDispositions(opts).filter((record) => record.policy_id === id);
|
|
1100
1113
|
const current = currentShadowDispositions(audit);
|
|
1101
1114
|
const history = this.repository.listDispositions(opts).filter((record) => record.policy_id === id && record.proof_id === proof.id);
|
|
1102
|
-
const scoringRecords = records.filter((record) => shadowCommitEligible(this.root, policy,
|
|
1103
|
-
// Workspace receipts retain their content-addressed pseudo-head for
|
|
1104
|
-
// dedupe, but ancestry eligibility is anchored to the real base commit.
|
|
1105
|
-
record.evaluation.repository.base ?? record.evaluation.repository.head));
|
|
1115
|
+
const scoringRecords = records.filter((record) => shadowCommitEligible(this.root, policy, shadowAncestryCommit(record)));
|
|
1106
1116
|
const report = scoreShadowPrecision(policy, proof, scoringRecords, audit, history, thresholds);
|
|
1107
1117
|
return {
|
|
1108
1118
|
...report,
|
package/dist/core/topics.js
CHANGED
|
@@ -59,14 +59,36 @@ export function captureConflicts(decisions, topic, selfId, willCloseId) {
|
|
|
59
59
|
* assembleContext, so no freshness re-check is needed here — a superseded-only-anchored
|
|
60
60
|
* file is caught by the anchor-stale drift check, and the commit-time staleness gate
|
|
61
61
|
* applies the age-downgrade. Returns "" when no anchored decision governs the file. */
|
|
62
|
-
export function renderGrounding(fileDecisions) {
|
|
62
|
+
export function renderGrounding(fileDecisions, allDecisions = fileDecisions) {
|
|
63
|
+
// A CONTESTED topic must never be stated as authority. This is the same fail-safe
|
|
64
|
+
// currentForTopic applies (`live.length === 1 ? live[0] : null`) and renderDocGrounding
|
|
65
|
+
// already honours — this reader was the one that bypassed it, filtering per-decision
|
|
66
|
+
// instead of per-topic. Two live decisions on one topic each got their own assertive
|
|
67
|
+
// bullet, and because each bullet lists what it REJECTED, the agent was told, in the
|
|
68
|
+
// last context before it writes, that both answers are correct and each is forbidden.
|
|
69
|
+
//
|
|
70
|
+
// `allDecisions` is the FULL set, not the file slice, on purpose: the colliding pair
|
|
71
|
+
// can name different files, so a file-scoped check would see one decision, call it
|
|
72
|
+
// uncontested, and assert it as THE answer while the topic is globally disputed.
|
|
73
|
+
const contested = topicCollisions(allDecisions);
|
|
63
74
|
const anchored = fileDecisions.filter((d) => d.topic && isLive(d));
|
|
64
75
|
if (!anchored.length)
|
|
65
76
|
return "";
|
|
66
|
-
const
|
|
77
|
+
const settled = anchored.filter((d) => !contested.has(d.topic));
|
|
78
|
+
const disputed = [...new Set(anchored.filter((d) => contested.has(d.topic)).map((d) => d.topic))].sort();
|
|
79
|
+
const lines = settled.map((d) => {
|
|
67
80
|
const rej = d.alternatives_rejected.length ? ` (rejected: ${d.alternatives_rejected.join("; ")})` : "";
|
|
68
81
|
return `• "${d.topic}": ${d.decision || d.title} [${d.id}]${rej}`;
|
|
69
82
|
});
|
|
83
|
+
// Name the conflict instead of silently dropping it: an unexplained absence would read
|
|
84
|
+
// as "nothing is recorded here", which is how a contested topic gets re-decided by
|
|
85
|
+
// accident. This is a question for the human, never an answer for the agent.
|
|
86
|
+
for (const topic of disputed) {
|
|
87
|
+
const ids = contested.get(topic).map((d) => d.id).join(", ");
|
|
88
|
+
lines.push(`• "${topic}": ⚠ UNRESOLVED — ${contested.get(topic).length} live decisions (${ids}). No current answer; ask the human before choosing. Resolve with \`hunch reconcile-topics\` (supersede one, or split the topic).`);
|
|
89
|
+
}
|
|
90
|
+
if (!lines.length)
|
|
91
|
+
return "";
|
|
70
92
|
return `🧭 Hunch grounding — this file is anchored to recorded decisions; follow the graph, not a stale doc:\n${lines.join("\n")}`;
|
|
71
93
|
}
|
|
72
94
|
/** Every topic with MORE THAN ONE live decision — the invariant violations a post-merge
|
package/dist/mcp/server.js
CHANGED
|
@@ -606,7 +606,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
606
606
|
L.push(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
|
|
607
607
|
if (pendingReview > 0)
|
|
608
608
|
L.push("", `${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` auto-trusts them as advisory (new captures land trusted automatically).`);
|
|
609
|
-
const escalations = pendingEscalations(store.
|
|
609
|
+
const escalations = pendingEscalations(store.advisoryRecs("decisions"));
|
|
610
610
|
if (escalations.length) {
|
|
611
611
|
L.push("", `⚖ ${escalations.length} decision(s) need the human's call — ASK inline (never queue): ${escalations.map((e) => e.question).join(" · ")}`);
|
|
612
612
|
}
|
|
@@ -621,10 +621,10 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
621
621
|
// (con_e04226bd05): no Claude-specific behavior.
|
|
622
622
|
server.registerTool("hunch_escalations", {
|
|
623
623
|
title: "Decisions the human must make now (ask inline, not a queue)",
|
|
624
|
-
description: "The rare decisions the graph cannot resolve on its own — surfaced so you ASK THE USER in the prompt at the moment, then act. Auto-captured memory is trusted automatically and never appears here; this returns topic conflicts (>1 live decision for one topic) and Constitution human moments (candidate policies awaiting review, proposed policies awaiting an activation decision). Normally empty. Raise each question with the user; do NOT decide it for them — an entry is a question, never an approval.
|
|
624
|
+
description: "The rare decisions the graph cannot resolve on its own — surfaced so you ASK THE USER in the prompt at the moment, then act. Auto-captured memory is trusted automatically and never appears here; this returns topic conflicts (>1 live decision for one topic) and Constitution human moments (candidate policies awaiting review, proposed policies awaiting an activation decision). Normally empty. Raise each question with the user; do NOT decide it for them — an entry is a question, never an approval. Reads the public store, or the unified overlay when the repo is in shared mode (where the overlay IS the store) — never private-mode overlay records.",
|
|
625
625
|
inputSchema: {},
|
|
626
626
|
}, async () => {
|
|
627
|
-
const items = pendingEscalations(store.
|
|
627
|
+
const items = pendingEscalations(store.advisoryRecs("decisions"));
|
|
628
628
|
try {
|
|
629
629
|
items.push(...policyEscalations(new ConstitutionService(store, root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
|
|
630
630
|
}
|
package/dist/store/db.js
CHANGED
|
@@ -59,22 +59,60 @@ function createDb(sqlitePath) {
|
|
|
59
59
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
60
60
|
return db;
|
|
61
61
|
}
|
|
62
|
+
/** Does this error mean the derived index FILE itself is unusable?
|
|
63
|
+
*
|
|
64
|
+
* Matched narrowly, on SQLite's own corruption signatures only. An environment failure —
|
|
65
|
+
* a permission denial, a full disk, a locked file — must still propagate: deleting the
|
|
66
|
+
* file would not fix it and would destroy a cache the user may still be able to keep.
|
|
67
|
+
* `SQLITE_CANTOPEN` ("unable to open database file") is deliberately NOT here for that
|
|
68
|
+
* reason: it usually means a permissions or path problem, not corruption. */
|
|
69
|
+
function isCorruptIndexFile(error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
return /database disk image is malformed|file is not a database|file is encrypted or is not a database|malformed database schema|database corruption/i.test(message);
|
|
72
|
+
}
|
|
73
|
+
function discardDerivedIndex(sqlitePath) {
|
|
74
|
+
for (const path of [sqlitePath, `${sqlitePath}-wal`, `${sqlitePath}-shm`])
|
|
75
|
+
rmSync(path, { force: true });
|
|
76
|
+
}
|
|
62
77
|
export function openDb(sqlitePath) {
|
|
63
78
|
mkdirSync(dirname(sqlitePath), { recursive: true });
|
|
64
|
-
|
|
79
|
+
// A corrupt file can fail at OPEN as well as at schema init (node:sqlite opens lazily,
|
|
80
|
+
// so "file is not a database" typically surfaces on the first statement — but not always).
|
|
81
|
+
let db;
|
|
82
|
+
try {
|
|
83
|
+
db = createDb(sqlitePath);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (!isCorruptIndexFile(error))
|
|
87
|
+
throw error;
|
|
88
|
+
discardDerivedIndex(sqlitePath);
|
|
89
|
+
db = createDb(sqlitePath);
|
|
90
|
+
}
|
|
65
91
|
try {
|
|
66
92
|
initializeSchema(db);
|
|
67
93
|
return db;
|
|
68
94
|
}
|
|
69
95
|
catch (error) {
|
|
70
|
-
|
|
96
|
+
// The ENTIRE database is derived from the Git-native JSON in .hunch/ — it is a cache,
|
|
97
|
+
// and a cache that cannot be read should be rebuilt, not fatal. That reasoning was
|
|
98
|
+
// already written here, but it was wired to exactly ONE trigger: RebuildDerivedIndex,
|
|
99
|
+
// thrown only when an fts5 index meets a runtime without the FTS5 module. Every other
|
|
100
|
+
// error propagated as a raw SQLite string, so a corrupt file took out `hunch index`,
|
|
101
|
+
// `query`, `check` and `doctor` at once — and, because the pre-edit hook must emit
|
|
102
|
+
// nothing and exit 0 on any failure (con_03a0b94b2e), it also went SILENTLY blind,
|
|
103
|
+
// permanently, with no command left that could repair it.
|
|
104
|
+
if (!(error instanceof RebuildDerivedIndex) && !isCorruptIndexFile(error)) {
|
|
71
105
|
db.close();
|
|
72
106
|
throw error;
|
|
73
107
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
108
|
+
try {
|
|
109
|
+
db.close();
|
|
110
|
+
}
|
|
111
|
+
catch { /* a corrupt handle may refuse to close; the file goes anyway */ }
|
|
112
|
+
discardDerivedIndex(sqlitePath);
|
|
77
113
|
db = createDb(sqlitePath);
|
|
114
|
+
// Deliberately NOT wrapped in another rebuild attempt: if a freshly created file also
|
|
115
|
+
// fails, the problem is the environment, not the cache, and it must surface.
|
|
78
116
|
initializeSchema(db);
|
|
79
117
|
return db;
|
|
80
118
|
}
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -226,6 +226,21 @@ export class HunchStore {
|
|
|
226
226
|
byId.set(r.id, r);
|
|
227
227
|
return [...byId.values()];
|
|
228
228
|
}
|
|
229
|
+
/** Records for an AGENT-FACING advisory surface (escalations, orientation).
|
|
230
|
+
*
|
|
231
|
+
* In unified ("shared") mode the overlay IS the one store — the public `.hunch/` is
|
|
232
|
+
* only a routing shell — so reading the public home alone returns NOTHING and the
|
|
233
|
+
* surface reports "all clear" for a store whose every record is elsewhere. That is
|
|
234
|
+
* the worst possible answer for escalations, whose entire job is to raise the
|
|
235
|
+
* questions only a human can settle: a real topic collision came back as an empty
|
|
236
|
+
* list and the agent was affirmatively told there was nothing to escalate.
|
|
237
|
+
*
|
|
238
|
+
* In "private" mode the split is a real privacy boundary, so this stays public-only:
|
|
239
|
+
* private records must not surface on a public advisory surface. Mode-aware, not a
|
|
240
|
+
* blanket union — the distinction is the point. */
|
|
241
|
+
advisoryRecs(kind) {
|
|
242
|
+
return this.unified ? this.recs(kind) : this.json.loadAll(kind);
|
|
243
|
+
}
|
|
229
244
|
/** Records from exactly one storage home (no public/private union). Capture
|
|
230
245
|
* paths use this for identity/lineage checks so a private record can never
|
|
231
246
|
* inherit or disclose relationships from an identically-shaped public record. */
|
|
@@ -389,21 +404,37 @@ export class HunchStore {
|
|
|
389
404
|
}
|
|
390
405
|
/** Portable bounded fallback over titles/bodies. Each natural-language token
|
|
391
406
|
* is an OR candidate, mirroring the high-recall FTS query closely enough for
|
|
392
|
-
* runtimes whose SQLite build omits the optional FTS5 module.
|
|
407
|
+
* runtimes whose SQLite build omits the optional FTS5 module.
|
|
408
|
+
*
|
|
409
|
+
* `_` is BOTH a LIKE single-character wildcard and the dominant character in this
|
|
410
|
+
* codebase's identifiers (dec_/con_/bug_ ids, snake_case symbols). The old code
|
|
411
|
+
* STRIPPED it, so a search for `hunch_record_decision` looked for the literal
|
|
412
|
+
* `hunchrecorddecision` and matched nothing — on precisely the runtimes with no FTS5,
|
|
413
|
+
* where this fallback is the only search there is. Escaping keeps the term literal;
|
|
414
|
+
* leaving `_` unescaped would silently over-match instead. */
|
|
393
415
|
likeSearch(query, limit, kind) {
|
|
394
416
|
const terms = (query.toLowerCase().match(/[\p{L}\p{N}_]+/gu)
|
|
395
|
-
?? [query.toLowerCase().
|
|
417
|
+
?? [query.toLowerCase().trim()].filter(Boolean)).slice(0, 32);
|
|
396
418
|
if (!terms.length)
|
|
397
419
|
return [];
|
|
398
|
-
const predicates = terms.map(() => `(lower(title) LIKE ? OR lower(body) LIKE ?)`).join(" OR ");
|
|
420
|
+
const predicates = terms.map(() => `(lower(title) LIKE ? ESCAPE '\\' OR lower(body) LIKE ? ESCAPE '\\')`).join(" OR ");
|
|
399
421
|
const likes = terms.flatMap((term) => {
|
|
400
|
-
const like = `%${term.replace(/[
|
|
422
|
+
const like = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
|
|
401
423
|
return [like, like];
|
|
402
424
|
});
|
|
403
425
|
const where = kind ? `kind = ? AND (${predicates})` : `(${predicates})`;
|
|
404
426
|
const params = kind ? [kind, ...likes, limit] : [...likes, limit];
|
|
427
|
+
// Ordered so a TRUNCATING limit drops the least relevant row rather than an
|
|
428
|
+
// arbitrary one: a title hit outranks a body-only hit, then shortest title
|
|
429
|
+
// (a constraint's one-line statement beats a long decision body that merely
|
|
430
|
+
// mentions the term), then id for determinism. Without this, `LIMIT` returned
|
|
431
|
+
// rowid order and could drop the constraint a caller was checking for.
|
|
432
|
+
const titleLikes = terms.map(() => `lower(title) LIKE ? ESCAPE '\\'`).join(" OR ");
|
|
433
|
+
const titleParams = terms.map((term) => `%${term.replace(/[\\%_]/g, "\\$&")}%`);
|
|
405
434
|
const rows = this.db.prepare(`SELECT ref, kind, title, substr(body,1,120) AS snip FROM search
|
|
406
|
-
WHERE ${where}
|
|
435
|
+
WHERE ${where}
|
|
436
|
+
ORDER BY CASE WHEN ${titleLikes} THEN 0 ELSE 1 END, length(title), ref
|
|
437
|
+
LIMIT ?`).all(...(kind ? [kind, ...likes, ...titleParams, limit] : [...likes, ...titleParams, limit]));
|
|
407
438
|
return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: 0 }));
|
|
408
439
|
}
|
|
409
440
|
// ---- semantic search (opt-in embeddings) --------------------------------
|
package/dist/store/merge.js
CHANGED
|
@@ -95,9 +95,49 @@ export function pickWinner(ours, theirs) {
|
|
|
95
95
|
const tr = recency(theirs);
|
|
96
96
|
if (orr !== tr)
|
|
97
97
|
return orr > tr ? ours : theirs;
|
|
98
|
+
// A merge must never silently UNDO recorded progress. Closing a bug does not touch
|
|
99
|
+
// `provenance` or `date` — captureTestRun writes
|
|
100
|
+
// `{ ...bug, status: "fixed", lineage: { ...lineage, fixed_commit: sha } }` — so
|
|
101
|
+
// recency() TIES against the still-open side and control always reached the
|
|
102
|
+
// lexicographic tiebreak below. That tiebreak then picked the LESS-resolved record,
|
|
103
|
+
// twice over: `"fixed_commit":null` sorts above `"fixed_commit":"<sha>"` (n > "), and
|
|
104
|
+
// `"status":"open"` sorts above `"status":"fixed"` (o > f). So a clean merge reverted
|
|
105
|
+
// the closure every time, in the subsystem whose entire job is not losing state.
|
|
106
|
+
//
|
|
107
|
+
// Prefer the side carrying more one-way lifecycle evidence. Deterministic and
|
|
108
|
+
// side-independent (a pure function of each record), so A-merges-B and B-merges-A
|
|
109
|
+
// still agree.
|
|
110
|
+
const oe = closureEvidence(ours);
|
|
111
|
+
const te = closureEvidence(theirs);
|
|
112
|
+
if (oe !== te)
|
|
113
|
+
return oe > te ? ours : theirs;
|
|
98
114
|
// Deterministic, side-independent tiebreak so A-merges-B and B-merges-A agree.
|
|
99
115
|
return canon(ours) >= canon(theirs) ? ours : theirs;
|
|
100
116
|
}
|
|
117
|
+
/** Count the one-way lifecycle facts a record carries: a fix commit, a spawned
|
|
118
|
+
* decision/constraint, a supersession, an end of validity. Each is something that
|
|
119
|
+
* HAPPENED and was recorded — never something a merge should quietly discard.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately counts EVIDENCE fields rather than reading `status`: a status string can
|
|
122
|
+
* be moved in either direction (a reopened bug goes fixed -> open), but a recorded
|
|
123
|
+
* `fixed_commit` is a fact about history. Ranking on evidence means a genuine reopen —
|
|
124
|
+
* which clears the commit — is still allowed to win, while a merge can no longer drop a
|
|
125
|
+
* closure that nobody reopened. */
|
|
126
|
+
function closureEvidence(r) {
|
|
127
|
+
let n = 0;
|
|
128
|
+
const lineage = r.lineage;
|
|
129
|
+
if (isRec(lineage)) {
|
|
130
|
+
for (const key of ["fixed_commit", "spawned_decision", "spawned_constraint"]) {
|
|
131
|
+
if (typeof lineage[key] === "string" && lineage[key].length > 0)
|
|
132
|
+
n += 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const key of ["superseded_by", "valid_to"]) {
|
|
136
|
+
if (typeof r[key] === "string" && r[key].length > 0)
|
|
137
|
+
n += 1;
|
|
138
|
+
}
|
|
139
|
+
return n;
|
|
140
|
+
}
|
|
101
141
|
function parseSide(text) {
|
|
102
142
|
const trimmed = (text ?? "").trim();
|
|
103
143
|
if (!trimmed)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.6",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Engineering memory and a deterministic Change Gate for AI-assisted codebases: decisions, rejected approaches, constraints, and bug lineage become portable context and opt-in enforcement for every MCP assistant.",
|