@davesheffer/hunch 0.8.0 → 0.9.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/LICENSE +21 -21
- package/README.md +16 -6
- package/dist/cli/index.js +92 -11
- package/dist/core/migrate.js +36 -2
- package/dist/core/types.js +21 -0
- package/dist/extractors/git.js +16 -0
- package/dist/extractors/parse.js +13 -13
- package/dist/mcp/server.js +47 -10
- package/dist/store/hunchStore.js +135 -8
- package/dist/synthesis/synthesize.js +12 -0
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Dave Sheffer
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dave Sheffer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -108,7 +108,9 @@ normally and Claude consults Hunch, or invoke the slash commands:
|
|
|
108
108
|
The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
|
|
109
109
|
`hunch_check_constraints`, `hunch_get_dependents` (blast radius), `hunch_blast_radius`
|
|
110
110
|
(dependent files + near-violations a change could break indirectly), `hunch_bug_lineage`,
|
|
111
|
-
`hunch_context` (surgical minimal slice for a task), `
|
|
111
|
+
`hunch_context` (surgical minimal slice for a task), `hunch_timeline` (a target's decision
|
|
112
|
+
history over time), `hunch_record_decision` (write-back). `hunch_why` and `hunch_context`
|
|
113
|
+
take an optional `as_of` (commit/tag/branch) to **time-travel** the graph to a past state.
|
|
112
114
|
|
|
113
115
|
### Works with any MCP assistant
|
|
114
116
|
|
|
@@ -139,12 +141,14 @@ preserved) and is idempotent. Opt out with `hunch init --no-providers`.
|
|
|
139
141
|
| `hunch record-constraint "<statement>" [--scope <globs>] [--severity advisory\|warning\|blocking] [--type …] [--rationale <t>] [--source-decision <id>]` | record an invariant the code must not break (what `hunch check` + the strict agent hook enforce) |
|
|
140
142
|
| `hunch firmness [off\|advisory\|firm\|strict]` | get/set how firmly the agent hook enforces Hunch before edits (no arg prints the current level) |
|
|
141
143
|
| `hunch test [cmd…]` | run the suite (default `npm test`); auto-capture failures as Bugs (suspects + recurrence→Constraints), mark passing tests' bugs fixed |
|
|
142
|
-
| `hunch why <path\|symbol
|
|
144
|
+
| `hunch why <path\|symbol> [--as-of <ref>]` | decisions / bugs / constraints explaining a target (flags `⚠STALE`); `--as-of` time-travels to what was believed at a commit/tag/branch |
|
|
145
|
+
| `hunch timeline <path\|symbol>` | the decision history for a target — what was believed, its valid-time window, and what superseded it |
|
|
146
|
+
| `hunch supersede <old> --by <new>` | mark one decision as replaced by another: closes the old one's valid-time window (invalidate, don't delete) |
|
|
143
147
|
| `hunch query "<q>" [--semantic]` | full-text + graph search (`--semantic` blends in local embeddings) |
|
|
144
148
|
| `hunch embed` | generate local embeddings for semantic recall (opt-in; needs `@huggingface/transformers`) |
|
|
145
|
-
| `hunch context <path\|symbol
|
|
149
|
+
| `hunch context <path\|symbol> [--as-of <ref>]` | minimal relevant slice for a task: invariants → decisions → bugs → blast radius (`--as-of` time-travels) |
|
|
146
150
|
| `hunch fragile` | ranked fragility report with evidence |
|
|
147
|
-
| `hunch check [--staged\|--commit <sha>] [--strict] [--blast]` | guardrail: flag changes touching a do-not-break invariant **directly or via blast radius** (a guarded file that depends on what you changed); `--blast` prints the dependency fan-out |
|
|
151
|
+
| `hunch check [--staged\|--commit <sha>] [--strict] [--blast]` | guardrail: flag changes touching a do-not-break invariant **directly or via blast radius** (a guarded file that depends on what you changed), **and changes that re-introduce something a decision deliberately retired** (the Regression Guard); `--blast` prints the dependency fan-out |
|
|
148
152
|
| `hunch stale [--resync]` | drift: records whose files changed after last verification (`--resync` regenerates stale decisions from their commits) |
|
|
149
153
|
| `hunch review [--accept <id>\|--reject <id>]` | curate: triage / promote / drop low-confidence drafts |
|
|
150
154
|
| `hunch migrate` | upgrade `.hunch/` records to the current schema version |
|
|
@@ -177,6 +181,12 @@ hunch firmness strict # change it (takes effect on the next edit; no restart
|
|
|
177
181
|
| `firm` | advisory **+** explicitly flag invariants in the file's scope |
|
|
178
182
|
| `strict` | firm **+** **deny** an edit that hits a *blocking* invariant (directly or via blast radius), feeding the invariant back as the refusal reason |
|
|
179
183
|
|
|
184
|
+
Before an edit, the hook also grounds the agent in anything an in-force decision
|
|
185
|
+
**deliberately retired** from that file ("don't re-introduce `login` here — dec_017 removed
|
|
186
|
+
it"). The actual gate is at commit time: `hunch check` runs the **Regression Guard** over
|
|
187
|
+
the staged diff and, under `--strict`, fails the commit when a change re-adds a retired
|
|
188
|
+
symbol/dependency tied to a blocking invariant (otherwise it warns).
|
|
189
|
+
|
|
180
190
|
The hook never breaks your flow: any error or unrecognized input emits nothing and exits
|
|
181
191
|
0, and it stays silent on files Hunch hasn't learned yet. `strict` only bites once you have
|
|
182
192
|
**blocking** constraints recorded (`hunch record-constraint … --severity blocking`) — with
|
|
@@ -330,5 +340,5 @@ npm test # node:test suite (store, graph, parse, indexer, synthesis,
|
|
|
330
340
|
npm run hunch -- why src/store/hunchStore.ts # run the CLI from source via tsx, no build
|
|
331
341
|
```
|
|
332
342
|
|
|
333
|
-
See [DESIGN.md](DESIGN.md) for the full spec. Deferred by design:
|
|
334
|
-
|
|
343
|
+
See [DESIGN.md](DESIGN.md) for the full spec. Deferred by design: PR/CI webhooks, a
|
|
344
|
+
web dashboard, and multi-repo support.
|
package/dist/cli/index.js
CHANGED
|
@@ -24,7 +24,8 @@ import { indexRepo } from "../extractors/indexer.js";
|
|
|
24
24
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
25
25
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
26
26
|
import { selectProvider } from "../synthesis/provider.js";
|
|
27
|
-
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles } from "../extractors/git.js";
|
|
27
|
+
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff } from "../extractors/git.js";
|
|
28
|
+
import { analyzeDiff } from "../extractors/diff.js";
|
|
28
29
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
29
30
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
30
31
|
import { updateClaudeMd } from "../integrations/claudemd.js";
|
|
@@ -293,12 +294,16 @@ program
|
|
|
293
294
|
.command("why")
|
|
294
295
|
.description("Explain why a file/symbol is the way it is (decisions, bugs, constraints).")
|
|
295
296
|
.argument("<target>", "file path or symbol name")
|
|
296
|
-
.
|
|
297
|
+
.option("--as-of <ref>", "time-travel: what was believed as of a commit/tag/branch (e.g. v0.7.0, HEAD~5)")
|
|
298
|
+
.action((target, opts) => {
|
|
297
299
|
const { store, root } = storeFor();
|
|
298
|
-
const
|
|
300
|
+
const asOf = opts.asOf ? asOfDate(opts.asOf, root) : undefined;
|
|
301
|
+
if (opts.asOf && !asOf)
|
|
302
|
+
return fail(`could not resolve --as-of "${opts.asOf}" to a commit (need a git repo and a valid ref)`);
|
|
303
|
+
const w = store.why(target, { asOf });
|
|
299
304
|
const staleIds = new Set(store.staleness((f) => lastChangeDate(f, root)).map((s) => s.id));
|
|
300
305
|
const drift = (id) => (staleIds.has(id) ? " ⚠STALE" : "");
|
|
301
|
-
console.log(`Why "${target}":\n`);
|
|
306
|
+
console.log(asOf ? `Why "${target}" (as of ${opts.asOf} — ${asOf.slice(0, 10)}):\n` : `Why "${target}":\n`);
|
|
302
307
|
if (w.decisions.length) {
|
|
303
308
|
console.log("DECISIONS:");
|
|
304
309
|
for (const d of w.decisions)
|
|
@@ -385,6 +390,9 @@ program
|
|
|
385
390
|
rationale: opts.rationale,
|
|
386
391
|
source_decision: opts.sourceDecision ?? null,
|
|
387
392
|
violations: [],
|
|
393
|
+
status: "active",
|
|
394
|
+
valid_from: new Date().toISOString(),
|
|
395
|
+
valid_to: null,
|
|
388
396
|
provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: new Date().toISOString() },
|
|
389
397
|
});
|
|
390
398
|
store.reindex();
|
|
@@ -538,6 +546,13 @@ program
|
|
|
538
546
|
}
|
|
539
547
|
}
|
|
540
548
|
}
|
|
549
|
+
// 3) REGRESSION — does the diff RE-ADD something an in-force decision removed?
|
|
550
|
+
// (e.g. re-introducing a symbol/dep that was deliberately deleted). Warn
|
|
551
|
+
// always; only a blocking-linked resurrection fails the commit under strict.
|
|
552
|
+
const diff = opts.commit ? commitDiff(opts.commit, root) : stagedDiff(root);
|
|
553
|
+
const an = analyzeDiff(diff);
|
|
554
|
+
const regHits = store.regressionHits({ symbols: an.addedSymbols.map((s) => s.name), deps: an.addedDeps }, files);
|
|
555
|
+
const regBlocking = regHits.filter((h) => h.blocking).length;
|
|
541
556
|
if (opts.blast) {
|
|
542
557
|
console.log(`Blast radius of ${files.length} changed file(s):`);
|
|
543
558
|
for (const f of files) {
|
|
@@ -547,8 +562,8 @@ program
|
|
|
547
562
|
}
|
|
548
563
|
console.log("");
|
|
549
564
|
}
|
|
550
|
-
if (!direct.size && !near.size) {
|
|
551
|
-
console.log(`✓ ${files.length} changed file(s) touch no recorded invariants (directly or via blast radius).`);
|
|
565
|
+
if (!direct.size && !near.size && !regHits.length) {
|
|
566
|
+
console.log(`✓ ${files.length} changed file(s) touch no recorded invariants (directly or via blast radius) and re-introduce nothing deliberately retired.`);
|
|
552
567
|
store.close();
|
|
553
568
|
return;
|
|
554
569
|
}
|
|
@@ -569,8 +584,18 @@ program
|
|
|
569
584
|
console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}\n ${c.id}\n ${via.slice(0, 4).join("\n ")}${via.length > 4 ? `\n …+${via.length - 4} more path(s)` : ""}`);
|
|
570
585
|
}
|
|
571
586
|
}
|
|
572
|
-
if (
|
|
573
|
-
console.log(
|
|
587
|
+
if (regHits.length) {
|
|
588
|
+
console.log(`${direct.size || near.size ? "\n" : ""}Re-introduces ${regHits.length} deliberately-retired item(s):\n`);
|
|
589
|
+
for (const h of regHits) {
|
|
590
|
+
console.log(` ${h.blocking ? "⛔" : "⚠"} re-adds ${h.kind} \`${h.name}\` — ${h.decision} removed it${h.blocking ? " (blocking-linked)" : ""}\n “${h.title}”\n ${h.reason}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (opts.strict && (blocking || regBlocking)) {
|
|
594
|
+
const reasons = [
|
|
595
|
+
blocking ? `${blocking} blocking invariant(s) in scope` : "",
|
|
596
|
+
regBlocking ? `${regBlocking} blocking-linked regression(s)` : "",
|
|
597
|
+
].filter(Boolean).join(" + ");
|
|
598
|
+
console.log(`\n✗ ${reasons} — review before committing.`);
|
|
574
599
|
process.exitCode = 1;
|
|
575
600
|
}
|
|
576
601
|
else {
|
|
@@ -584,10 +609,58 @@ program
|
|
|
584
609
|
.description("Assemble the minimal relevant Hunch slice for a task on a file/symbol.")
|
|
585
610
|
.argument("<target>", "file path or symbol")
|
|
586
611
|
.option("--budget <n>", "rough token budget", "1500")
|
|
612
|
+
.option("--as-of <ref>", "time-travel: assemble the slice as it stood at a commit/tag/branch")
|
|
587
613
|
.action((target, opts) => {
|
|
588
|
-
const { store } = storeFor();
|
|
614
|
+
const { store, root } = storeFor();
|
|
615
|
+
const asOf = opts.asOf ? asOfDate(opts.asOf, root) : undefined;
|
|
616
|
+
if (opts.asOf && !asOf)
|
|
617
|
+
return fail(`could not resolve --as-of "${opts.asOf}" to a commit`);
|
|
589
618
|
store.reindex(); // reflect any out-of-band JSON edits before assembling
|
|
590
|
-
process.stdout.write(formatContext(store.assembleContext(target, Number(opts.budget))));
|
|
619
|
+
process.stdout.write(formatContext(store.assembleContext(target, Number(opts.budget), { asOf })));
|
|
620
|
+
store.close();
|
|
621
|
+
});
|
|
622
|
+
// ---- timeline -------------------------------------------------------------
|
|
623
|
+
program
|
|
624
|
+
.command("timeline")
|
|
625
|
+
.description("Time-travel: the decision history for a file/symbol — what was believed, and when/why it changed.")
|
|
626
|
+
.argument("<target>", "file path or symbol name")
|
|
627
|
+
.action((target) => {
|
|
628
|
+
const { store } = storeFor();
|
|
629
|
+
const tl = store.timeline(target);
|
|
630
|
+
if (!tl.length) {
|
|
631
|
+
console.log(`No decision history for "${target}" yet.`);
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
console.log(`Decision timeline for "${target}" (newest first):\n`);
|
|
635
|
+
for (const d of tl) {
|
|
636
|
+
const from = (d.valid_from ?? d.date).slice(0, 10);
|
|
637
|
+
const window = d.valid_to ? `${from} → ${d.valid_to.slice(0, 10)}` : `${from} → now`;
|
|
638
|
+
const sup = d.superseded_by ? ` ↦ superseded by ${d.superseded_by}` : "";
|
|
639
|
+
console.log(` • ${d.id} [${d.status}] (${window})${sup}\n ${d.title}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
store.close();
|
|
643
|
+
});
|
|
644
|
+
// ---- supersede ------------------------------------------------------------
|
|
645
|
+
program
|
|
646
|
+
.command("supersede")
|
|
647
|
+
.description("Mark one decision as replaced by another: closes the old one's valid-time window (invalidate, don't delete).")
|
|
648
|
+
.argument("<old>", "decision id being replaced")
|
|
649
|
+
.requiredOption("--by <new>", "decision id that supersedes it")
|
|
650
|
+
.action((oldId, opts) => {
|
|
651
|
+
const { store } = storeFor();
|
|
652
|
+
const by = store.json.get("decisions", opts.by);
|
|
653
|
+
if (!by) {
|
|
654
|
+
store.close();
|
|
655
|
+
return fail(`--by decision "${opts.by}" not found`);
|
|
656
|
+
}
|
|
657
|
+
const closed = store.supersede(oldId, by);
|
|
658
|
+
if (!closed) {
|
|
659
|
+
store.close();
|
|
660
|
+
return fail(`decision "${oldId}" not found (or same as --by)`);
|
|
661
|
+
}
|
|
662
|
+
store.reindex();
|
|
663
|
+
console.log(`✓ ${oldId} superseded by ${opts.by} — window closed at ${closed.valid_to?.slice(0, 10)}.`);
|
|
591
664
|
store.close();
|
|
592
665
|
});
|
|
593
666
|
// ---- firmness (agent-hook enforcement level) ------------------------------
|
|
@@ -654,7 +727,11 @@ program
|
|
|
654
727
|
}
|
|
655
728
|
// advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
|
|
656
729
|
const ctx = store.assembleContext(target);
|
|
657
|
-
|
|
730
|
+
// Regression Guard (edit-time grounding): what an in-force decision retired
|
|
731
|
+
// from this file. No diff exists yet, so this is context — "don't re-add X" —
|
|
732
|
+
// not a block; the commit-time `hunch check` does the actual gating.
|
|
733
|
+
const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
|
|
734
|
+
const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || retired.length;
|
|
658
735
|
if (!hasContent)
|
|
659
736
|
return; // no noise on files Hunch hasn't learned yet
|
|
660
737
|
let text = formatContext(ctx).trim();
|
|
@@ -662,6 +739,10 @@ program
|
|
|
662
739
|
const names = ctx.constraints.map((c) => `[${c.severity}] ${c.statement}`).join("; ");
|
|
663
740
|
text += `\n\n⚠ This file is in scope of ${ctx.constraints.length} invariant(s): ${names}. Preserve them.`;
|
|
664
741
|
}
|
|
742
|
+
if (retired.length) {
|
|
743
|
+
const items = retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ");
|
|
744
|
+
text += `\n\n⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${items}.`;
|
|
745
|
+
}
|
|
665
746
|
emitContext("PreToolUse", text);
|
|
666
747
|
}
|
|
667
748
|
catch {
|
package/dist/core/migrate.js
CHANGED
|
@@ -18,13 +18,47 @@ import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
|
18
18
|
import { dirname } from "node:path";
|
|
19
19
|
import { writeFileAtomic } from "./io.js";
|
|
20
20
|
/** The schema generation this build writes and reads. Bump on any breaking change. */
|
|
21
|
-
export const SCHEMA_VERSION =
|
|
21
|
+
export const SCHEMA_VERSION = 2;
|
|
22
22
|
/** A repo whose `.hunch/` predates manifests is treated as v1. Migrations are
|
|
23
23
|
* numbered from 2 (each `version` is the number it PRODUCES), so a baseline repo
|
|
24
24
|
* runs every migration with version >= 2 — never author a no-op version:1 one. */
|
|
25
25
|
export const BASELINE_VERSION = 1;
|
|
26
26
|
/** Ordered, ascending by `version`. Empty at v1 (baseline); future versions append. */
|
|
27
|
-
export const MIGRATIONS = [
|
|
27
|
+
export const MIGRATIONS = [
|
|
28
|
+
{
|
|
29
|
+
// v2: bi-temporal valid-time on decisions + constraints (Time-Travel Memory).
|
|
30
|
+
// Backfill new fields from each record's existing date so a v1 graph migrates
|
|
31
|
+
// losslessly — no record is dropped, and `valid_from` is populated BEFORE the
|
|
32
|
+
// Zod pass. Defensive: input is untrusted JSON.
|
|
33
|
+
version: 2,
|
|
34
|
+
description: "Add valid_from/valid_to/superseded_by/retired (decisions) and status/valid_from/valid_to (constraints)",
|
|
35
|
+
up(kind, raw) {
|
|
36
|
+
if (kind === "decisions") {
|
|
37
|
+
const date = typeof raw.date === "string" ? raw.date : "";
|
|
38
|
+
if (raw.valid_from === undefined)
|
|
39
|
+
raw.valid_from = date;
|
|
40
|
+
// Legacy superseded decisions have no recorded successor instant. Leave
|
|
41
|
+
// valid_to = null (historically in force) rather than = date: a zero-length
|
|
42
|
+
// [date,date) window matches NO as-of query and would hide the record from
|
|
43
|
+
// all time-travel. A later `supersede` sets a real valid_to when known.
|
|
44
|
+
if (raw.valid_to === undefined)
|
|
45
|
+
raw.valid_to = null;
|
|
46
|
+
if (raw.superseded_by === undefined)
|
|
47
|
+
raw.superseded_by = null;
|
|
48
|
+
if (raw.retired === undefined)
|
|
49
|
+
raw.retired = { symbols: [], deps: [] };
|
|
50
|
+
}
|
|
51
|
+
else if (kind === "constraints") {
|
|
52
|
+
if (raw.status === undefined)
|
|
53
|
+
raw.status = "active";
|
|
54
|
+
if (raw.valid_to === undefined)
|
|
55
|
+
raw.valid_to = null;
|
|
56
|
+
// valid_from is optional on constraints; leave unset for legacy records.
|
|
57
|
+
}
|
|
58
|
+
return raw;
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
];
|
|
28
62
|
/** Read `.hunch/manifest.json`. A missing/corrupt manifest is treated as the
|
|
29
63
|
* BASELINE version (a pre-manifest `.hunch/`), so future builds still migrate it. */
|
|
30
64
|
export function readManifest(paths) {
|
package/dist/core/types.js
CHANGED
|
@@ -68,6 +68,12 @@ export const SymbolSchema = z.object({
|
|
|
68
68
|
metrics: SymbolMetricsSchema.default({ loc: 0, churn_90d: 0, bug_count: 0, fan_in: 0, fan_out: 0 }),
|
|
69
69
|
last_changed: z.string().default("").describe("commit:<sha> or ISO date"),
|
|
70
70
|
});
|
|
71
|
+
/** The structural delta a decision's commit DELETED — the evidence the Regression
|
|
72
|
+
* Guard matches a later diff against ("you're re-adding what dec_X removed"). */
|
|
73
|
+
export const RetiredSignalSchema = z.object({
|
|
74
|
+
symbols: z.array(z.string()).default([]).describe("symbol names this decision removed"),
|
|
75
|
+
deps: z.array(z.string()).default([]).describe("external deps this decision dropped"),
|
|
76
|
+
});
|
|
71
77
|
/** ADR-style decision record, auto-drafted and human-confirmable. */
|
|
72
78
|
export const DecisionSchema = z.object({
|
|
73
79
|
id: z.string().describe("dec_*"),
|
|
@@ -80,8 +86,17 @@ export const DecisionSchema = z.object({
|
|
|
80
86
|
related_components: z.array(z.string()).default([]),
|
|
81
87
|
related_files: z.array(z.string()).default([]),
|
|
82
88
|
supersedes: z.string().nullable().default(null),
|
|
89
|
+
superseded_by: z.string().nullable().default(null).describe("the decision that closed this one's window"),
|
|
83
90
|
caused_by_bug: z.string().nullable().default(null),
|
|
84
91
|
commit: z.string().nullable().default(null),
|
|
92
|
+
// Bi-temporal VALID-TIME window, git-anchored. `valid_from` is when the decision
|
|
93
|
+
// took effect (its commit date); `valid_to` is when a superseding decision closed
|
|
94
|
+
// it (null = still in force). Enables "what did we believe as of commit X?".
|
|
95
|
+
// Optional so legacy/hand-built records still validate (the migration backfills
|
|
96
|
+
// from `date`, and the capture paths always set it); undefined = always-started.
|
|
97
|
+
valid_from: z.string().optional().describe("ISO instant the decision took effect (commit date)"),
|
|
98
|
+
valid_to: z.string().nullable().default(null).describe("ISO instant it was superseded (null = in force)"),
|
|
99
|
+
retired: RetiredSignalSchema.default({ symbols: [], deps: [] }),
|
|
85
100
|
provenance: ProvenanceSchema,
|
|
86
101
|
date: z.string(),
|
|
87
102
|
});
|
|
@@ -120,6 +135,12 @@ export const ConstraintSchema = z.object({
|
|
|
120
135
|
rationale: z.string().default(""),
|
|
121
136
|
source_decision: z.string().nullable().default(null),
|
|
122
137
|
violations: z.array(z.string()).default([]),
|
|
138
|
+
// Bi-temporal VALID-TIME: a constraint can be RETIRED without deletion, so
|
|
139
|
+
// "what invariants were in force as of commit X?" stays answerable. `valid_to`
|
|
140
|
+
// null = still active. A retired constraint is excluded from enforcement at HEAD.
|
|
141
|
+
status: z.enum(["active", "retired"]).default("active"),
|
|
142
|
+
valid_from: z.string().optional().describe("ISO instant the invariant took effect"),
|
|
143
|
+
valid_to: z.string().nullable().default(null).describe("ISO instant it was retired (null = active)"),
|
|
123
144
|
provenance: ProvenanceSchema,
|
|
124
145
|
});
|
|
125
146
|
/** The six entity collections, keyed by their on-disk directory name. */
|
package/dist/extractors/git.js
CHANGED
|
@@ -105,6 +105,22 @@ export function stagedFiles(cwd) {
|
|
|
105
105
|
const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
106
106
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
107
107
|
}
|
|
108
|
+
/** Unified diff of the staged changes (for the Regression Guard's structural
|
|
109
|
+
* analysis). Excludes machine-generated noise and truncates at the SAME budget as
|
|
110
|
+
* commitDiff, so the staged and `--commit` guard paths can't diverge on big diffs. */
|
|
111
|
+
export function stagedDiff(cwd, maxBytes = 60_000) {
|
|
112
|
+
const out = gitSafe(["diff", "--cached", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
113
|
+
return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
|
|
114
|
+
}
|
|
115
|
+
/** Resolve a time-travel ref (commit / tag / branch / HEAD~n) to the ISO author-
|
|
116
|
+
* date of that commit — the instant valid-time windows are filtered against.
|
|
117
|
+
* Undefined if it can't be resolved (not a git repo, or an unknown ref). Single
|
|
118
|
+
* source for the CLI and MCP as-of paths so they can't drift. */
|
|
119
|
+
export function asOfDate(ref, cwd) {
|
|
120
|
+
if (!isGitRepo(cwd))
|
|
121
|
+
return undefined;
|
|
122
|
+
return commitMeta(revParse(ref, cwd), cwd)?.date || undefined;
|
|
123
|
+
}
|
|
108
124
|
/** Translate a backfill window spec into git-log window args.
|
|
109
125
|
* "90d" / bare "90" -> last 90 days | "40c" -> last 40 commits
|
|
110
126
|
* anything else -> passed to --since as an approxidate/date string. */
|
package/dist/extractors/parse.js
CHANGED
|
@@ -26,19 +26,19 @@ const BUILTIN_METHODS = new Set([
|
|
|
26
26
|
"log", "error", "warn", "info", "debug",
|
|
27
27
|
]);
|
|
28
28
|
/** Tree-sitter query capturing every construct we care about in one pass. */
|
|
29
|
-
const QUERY_SRC = `
|
|
30
|
-
(function_declaration name: (identifier) @fn.name) @fn.def
|
|
31
|
-
(generator_function_declaration name: (identifier) @fn.name) @fn.def
|
|
32
|
-
(method_definition name: (property_identifier) @method.name) @method.def
|
|
33
|
-
(class_declaration name: (type_identifier) @class.name) @class.def
|
|
34
|
-
(interface_declaration name: (type_identifier) @iface.name) @iface.def
|
|
35
|
-
(type_alias_declaration name: (type_identifier) @type.name) @type.def
|
|
36
|
-
(variable_declarator
|
|
37
|
-
name: (identifier) @arrow.name
|
|
38
|
-
value: [(arrow_function) (function_expression)]) @arrow.def
|
|
39
|
-
(import_statement source: (string) @import.src)
|
|
40
|
-
(call_expression function: (identifier) @call.id)
|
|
41
|
-
(call_expression function: (member_expression property: (property_identifier) @call.member))
|
|
29
|
+
const QUERY_SRC = `
|
|
30
|
+
(function_declaration name: (identifier) @fn.name) @fn.def
|
|
31
|
+
(generator_function_declaration name: (identifier) @fn.name) @fn.def
|
|
32
|
+
(method_definition name: (property_identifier) @method.name) @method.def
|
|
33
|
+
(class_declaration name: (type_identifier) @class.name) @class.def
|
|
34
|
+
(interface_declaration name: (type_identifier) @iface.name) @iface.def
|
|
35
|
+
(type_alias_declaration name: (type_identifier) @type.name) @type.def
|
|
36
|
+
(variable_declarator
|
|
37
|
+
name: (identifier) @arrow.name
|
|
38
|
+
value: [(arrow_function) (function_expression)]) @arrow.def
|
|
39
|
+
(import_statement source: (string) @import.src)
|
|
40
|
+
(call_expression function: (identifier) @call.id)
|
|
41
|
+
(call_expression function: (member_expression property: (property_identifier) @call.member))
|
|
42
42
|
`;
|
|
43
43
|
const cache = new Map();
|
|
44
44
|
function bundleFor(lang, key) {
|
package/dist/mcp/server.js
CHANGED
|
@@ -13,7 +13,7 @@ import { hunchPaths, findRoot } from "../core/paths.js";
|
|
|
13
13
|
import { HunchStore } from "../store/hunchStore.js";
|
|
14
14
|
import { selectEmbedder } from "../store/embedder.js";
|
|
15
15
|
import { decisionId } from "../core/ids.js";
|
|
16
|
-
import { revParse } from "../extractors/git.js";
|
|
16
|
+
import { revParse, asOfDate } from "../extractors/git.js";
|
|
17
17
|
import { formatContext } from "../core/format.js";
|
|
18
18
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
19
19
|
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
@@ -76,10 +76,16 @@ export function buildServer(root) {
|
|
|
76
76
|
// -- hunch_why ------------------------------------------------------------
|
|
77
77
|
server.registerTool("hunch_why", {
|
|
78
78
|
title: "Explain why a file/symbol is the way it is",
|
|
79
|
-
description: "Return the decisions, bugs, and constraints that explain a file path or symbol — the 'why' and the 'what must not break', with evidence.",
|
|
80
|
-
inputSchema: {
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
description: "Return the decisions, bugs, and constraints that explain a file path or symbol — the 'why' and the 'what must not break', with evidence. Pass `as_of` (a commit/tag/branch) to time-travel: see what was believed at that point in history.",
|
|
80
|
+
inputSchema: {
|
|
81
|
+
target: z.string().describe("A file path (e.g. src/auth/session.ts) or symbol name."),
|
|
82
|
+
as_of: z.string().optional().describe("Time-travel ref: a commit sha, tag, or branch (e.g. v0.7.0). Omit for the current view."),
|
|
83
|
+
},
|
|
84
|
+
}, async ({ target, as_of }) => {
|
|
85
|
+
const asOf = as_of ? asOfDate(as_of, root) : undefined;
|
|
86
|
+
if (as_of && !asOf)
|
|
87
|
+
return err(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
88
|
+
const w = store.why(target, { asOf });
|
|
83
89
|
// Highest-signal first, then cap: invariants by severity, decisions by
|
|
84
90
|
// confidence, bugs by severity — so a hot file's trim drops the tail, not
|
|
85
91
|
// the records that matter most.
|
|
@@ -185,9 +191,30 @@ export function buildServer(root) {
|
|
|
185
191
|
inputSchema: {
|
|
186
192
|
target: z.string().describe("A file path or symbol you're about to edit."),
|
|
187
193
|
budget_tokens: z.number().optional().describe("Rough token budget for the brief (default 1500)."),
|
|
194
|
+
as_of: z.string().optional().describe("Time-travel ref (commit/tag/branch): assemble the slice as it stood then."),
|
|
188
195
|
},
|
|
189
|
-
}, async ({ target, budget_tokens }) => {
|
|
190
|
-
|
|
196
|
+
}, async ({ target, budget_tokens, as_of }) => {
|
|
197
|
+
const asOf = as_of ? asOfDate(as_of, root) : undefined;
|
|
198
|
+
if (as_of && !asOf)
|
|
199
|
+
return err(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
200
|
+
return ok(formatContext(store.assembleContext(target, budget_tokens ?? 1500, { asOf })));
|
|
201
|
+
});
|
|
202
|
+
// -- hunch_timeline (decision history) ------------------------------------
|
|
203
|
+
server.registerTool("hunch_timeline", {
|
|
204
|
+
title: "The decision history for a file/symbol",
|
|
205
|
+
description: "Time-travel: the decisions touching a file/symbol over time — what was believed, its valid-time window, and what superseded it. Use to understand how (and why) the design changed, and to avoid re-introducing a deliberately-retired approach.",
|
|
206
|
+
inputSchema: { target: z.string().describe("A file path or symbol name.") },
|
|
207
|
+
}, async ({ target }) => {
|
|
208
|
+
const tl = store.timeline(target);
|
|
209
|
+
if (!tl.length)
|
|
210
|
+
return ok(`No decision history for "${target}" yet.`);
|
|
211
|
+
const lines = tl.map((d) => {
|
|
212
|
+
const from = (d.valid_from ?? d.date).slice(0, 10);
|
|
213
|
+
const window = d.valid_to ? `${from} → ${d.valid_to.slice(0, 10)}` : `${from} → now`;
|
|
214
|
+
const sup = d.superseded_by ? ` (superseded by ${d.superseded_by})` : "";
|
|
215
|
+
return ` • ${d.id} [${d.status}] (${window})${sup}\n ${d.title}`;
|
|
216
|
+
});
|
|
217
|
+
return ok(`Decision timeline for "${target}" (newest first):\n${lines.join("\n")}`);
|
|
191
218
|
});
|
|
192
219
|
// -- hunch_record_decision (write-back) -----------------------------------
|
|
193
220
|
server.registerTool("hunch_record_decision", {
|
|
@@ -204,6 +231,7 @@ export function buildServer(root) {
|
|
|
204
231
|
related_components: z.array(z.string()).optional(),
|
|
205
232
|
status: z.enum(["proposed", "accepted", "rejected", "superseded"]).optional(),
|
|
206
233
|
commit: z.string().optional(),
|
|
234
|
+
supersedes: z.string().optional().describe("id of a decision this one replaces — closes its valid-time window (invalidate, don't delete)"),
|
|
207
235
|
}),
|
|
208
236
|
},
|
|
209
237
|
}, async ({ decision }) => {
|
|
@@ -223,6 +251,7 @@ export function buildServer(root) {
|
|
|
223
251
|
const source = existing && existing.provenance.source.includes("llm_draft")
|
|
224
252
|
? "llm_draft+human_confirmed"
|
|
225
253
|
: "human_confirmed";
|
|
254
|
+
const now = new Date().toISOString();
|
|
226
255
|
const rec = {
|
|
227
256
|
id,
|
|
228
257
|
title: decision.title,
|
|
@@ -233,16 +262,24 @@ export function buildServer(root) {
|
|
|
233
262
|
alternatives_rejected: decision.alternatives_rejected ?? [],
|
|
234
263
|
related_components: decision.related_components ?? existing?.related_components ?? [],
|
|
235
264
|
related_files: decision.related_files ?? existing?.related_files ?? [],
|
|
236
|
-
supersedes: existing?.supersedes ?? null,
|
|
265
|
+
supersedes: decision.supersedes ?? existing?.supersedes ?? null,
|
|
266
|
+
superseded_by: existing?.superseded_by ?? null,
|
|
237
267
|
caused_by_bug: existing?.caused_by_bug ?? null,
|
|
238
268
|
commit: decision.commit ?? existing?.commit ?? null,
|
|
269
|
+
valid_from: existing?.valid_from ?? now,
|
|
270
|
+
valid_to: existing?.valid_to ?? null,
|
|
271
|
+
retired: existing?.retired ?? { symbols: [], deps: [] },
|
|
239
272
|
provenance: { source, confidence: 0.95, evidence: decision.related_files ?? existing?.provenance.evidence ?? [] },
|
|
240
|
-
date:
|
|
273
|
+
date: now,
|
|
241
274
|
};
|
|
242
275
|
store.json.put("decisions", rec);
|
|
276
|
+
// Invalidate, don't delete: closing the superseded decision's valid-time
|
|
277
|
+
// window (+ a supersedes edge) preserves the why-it-changed trail.
|
|
278
|
+
const superseded = decision.supersedes ? store.supersede(decision.supersedes, rec) : null;
|
|
243
279
|
store.reindex();
|
|
280
|
+
const supNote = superseded ? ` Superseded ${superseded.id} (window closed at ${rec.valid_from}).` : "";
|
|
244
281
|
const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved — recorded as a standalone decision, not linked to a commit)` : "";
|
|
245
|
-
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${note}`);
|
|
282
|
+
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${supNote}${note}`);
|
|
246
283
|
}
|
|
247
284
|
catch (e) {
|
|
248
285
|
return err(`Failed to record decision: ${e.message}`);
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -4,6 +4,7 @@ import { RESET_SQL, embedHash } from "./schema.js";
|
|
|
4
4
|
import { selectEmbedder } from "./embedder.js";
|
|
5
5
|
import { JsonStore } from "./jsonStore.js";
|
|
6
6
|
import { pathMatchesGlob } from "../core/glob.js";
|
|
7
|
+
import { edgeId } from "../core/ids.js";
|
|
7
8
|
export class HunchStore {
|
|
8
9
|
paths;
|
|
9
10
|
json;
|
|
@@ -273,13 +274,17 @@ export class HunchStore {
|
|
|
273
274
|
return [...acc.values()].sort((a, b) => b.score - a.score).slice(0, limit).map((e) => ({ ...e.hit, score: e.score }));
|
|
274
275
|
}
|
|
275
276
|
/** All decisions/bugs/constraints/symbols/components touching a file path or
|
|
276
|
-
* symbol name (hunch_why).
|
|
277
|
-
|
|
277
|
+
* symbol name (hunch_why). Pass `{ asOf }` (an ISO instant) to TIME-TRAVEL:
|
|
278
|
+
* return only decisions/constraints whose valid-time window contained that
|
|
279
|
+
* instant — "what did we believe as of commit X?". Omit `asOf` for the full,
|
|
280
|
+
* history-inclusive view (backward-compatible default). */
|
|
281
|
+
why(target, opts = {}) {
|
|
278
282
|
const decisions = this.json.loadAll("decisions");
|
|
279
283
|
const bugs = this.json.loadAll("bugs");
|
|
280
284
|
const constraints = this.json.loadAll("constraints");
|
|
281
285
|
const symbols = this.json.loadAll("symbols");
|
|
282
286
|
const components = this.json.loadAll("components");
|
|
287
|
+
const asOf = opts.asOf;
|
|
283
288
|
const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || s.file.endsWith(target));
|
|
284
289
|
const symIds = new Set(matchedSymbols.map((s) => s.id));
|
|
285
290
|
const fileSet = new Set(matchedSymbols.map((s) => s.file));
|
|
@@ -287,9 +292,11 @@ export class HunchStore {
|
|
|
287
292
|
const fileMatch = (files) => files.some((f) => f === target || (isPath && (f.endsWith(target) || target.endsWith(f))) || fileSet.has(f));
|
|
288
293
|
return {
|
|
289
294
|
target,
|
|
290
|
-
decisions: decisions.filter((d) => fileMatch(d.related_files) || d.related_components.some((c) => components.find((x) => x.id === c && fileMatch(x.paths))))
|
|
295
|
+
decisions: decisions.filter((d) => (fileMatch(d.related_files) || d.related_components.some((c) => components.find((x) => x.id === c && fileMatch(x.paths))))
|
|
296
|
+
&& inWindow(d.valid_from, d.valid_to, asOf)),
|
|
291
297
|
bugs: bugs.filter((b) => fileMatch(b.affected_files) || b.affected_symbols.some((s) => symIds.has(s))),
|
|
292
|
-
constraints: constraints.filter((c) => c.scope.some((g) => pathMatchesGlob(target, g) || [...fileSet].some((f) => pathMatchesGlob(f, g)))
|
|
298
|
+
constraints: constraints.filter((c) => c.scope.some((g) => pathMatchesGlob(target, g) || [...fileSet].some((f) => pathMatchesGlob(f, g)))
|
|
299
|
+
&& inWindow(c.valid_from, c.valid_to, asOf)),
|
|
293
300
|
symbols: matchedSymbols,
|
|
294
301
|
components: components.filter((c) => c.paths.some((g) => pathMatchesGlob(target, g) || [...fileSet].some((f) => pathMatchesGlob(f, g)))),
|
|
295
302
|
};
|
|
@@ -357,13 +364,114 @@ export class HunchStore {
|
|
|
357
364
|
}
|
|
358
365
|
return [...out.values()].sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file));
|
|
359
366
|
}
|
|
360
|
-
/** Constraints whose scope glob matches a path/glob (hunch_check_constraints).
|
|
361
|
-
|
|
367
|
+
/** Constraints whose scope glob matches a path/glob (hunch_check_constraints).
|
|
368
|
+
* By default only ACTIVE invariants are returned — a retired constraint is no
|
|
369
|
+
* longer enforced. Pass `{ asOf }` to instead return the invariants in force at
|
|
370
|
+
* that instant (time-travel: "what must I not have broken as of commit X?"). */
|
|
371
|
+
checkConstraints(scope, opts = {}) {
|
|
362
372
|
const all = this.json.loadAll("constraints");
|
|
373
|
+
const asOf = opts.asOf;
|
|
363
374
|
return all
|
|
364
375
|
.filter((c) => c.scope.some((g) => pathMatchesGlob(scope, g) || pathMatchesGlob(g, scope) || g === scope))
|
|
376
|
+
.filter((c) => (asOf ? inWindow(c.valid_from, c.valid_to, asOf) : c.status !== "retired"))
|
|
365
377
|
.sort((a, b) => sev(b.severity) - sev(a.severity));
|
|
366
378
|
}
|
|
379
|
+
/** Time-travel: the decision history for a target — every decision touching it,
|
|
380
|
+
* newest-first, with its valid-time window and supersession links. Answers
|
|
381
|
+
* "what did we believe, and when/why did it change?" (hunch_timeline). */
|
|
382
|
+
timeline(target) {
|
|
383
|
+
return this.why(target).decisions.sort((a, b) => (b.valid_from ?? b.date).localeCompare(a.valid_from ?? a.date));
|
|
384
|
+
}
|
|
385
|
+
/** Invalidate, don't delete (Zep edge-invalidation): close `oldId`'s valid-time
|
|
386
|
+
* window at the superseding decision's `valid_from`, mark it superseded + linked,
|
|
387
|
+
* and write a `supersedes` edge. Returns the updated old decision, or null if it
|
|
388
|
+
* doesn't exist. All writes are atomic via json.put (con_902759b3dc). */
|
|
389
|
+
supersede(oldId, by) {
|
|
390
|
+
const old = this.json.get("decisions", oldId);
|
|
391
|
+
if (!old || old.id === by.id)
|
|
392
|
+
return null;
|
|
393
|
+
const closed = {
|
|
394
|
+
...old,
|
|
395
|
+
status: "superseded",
|
|
396
|
+
superseded_by: by.id,
|
|
397
|
+
valid_to: old.valid_to ?? by.valid_from ?? null,
|
|
398
|
+
};
|
|
399
|
+
this.json.put("decisions", closed);
|
|
400
|
+
const edge = {
|
|
401
|
+
id: edgeId(by.id, oldId, "supersedes"),
|
|
402
|
+
from: by.id,
|
|
403
|
+
to: oldId,
|
|
404
|
+
type: "supersedes",
|
|
405
|
+
reason: `${by.id} supersedes ${oldId}`,
|
|
406
|
+
strength: 1,
|
|
407
|
+
provenance: { source: "derived", confidence: 1, evidence: [by.id, oldId] },
|
|
408
|
+
};
|
|
409
|
+
this.json.put("edges", edge);
|
|
410
|
+
return closed;
|
|
411
|
+
}
|
|
412
|
+
/** Regression Guard: detect a change RE-INTRODUCING something an in-force
|
|
413
|
+
* decision deliberately removed. Matches the added symbols/deps of a diff
|
|
414
|
+
* against the `retired` signal of decisions concerning the touched files. A hit
|
|
415
|
+
* is `blocking` when the retiring decision is tied to an ACTIVE blocking
|
|
416
|
+
* constraint (via source_decision) — that's the only case the strict guard
|
|
417
|
+
* fails the commit on; everything else is an advisory warning. */
|
|
418
|
+
regressionHits(added, files) {
|
|
419
|
+
const addedSyms = new Set(added.symbols);
|
|
420
|
+
const addedDeps = new Set(added.deps);
|
|
421
|
+
if (!addedSyms.size && !addedDeps.size)
|
|
422
|
+
return [];
|
|
423
|
+
const fileRelevant = (related) => related.some((f) => files.some((x) => pathRelated(x, f)));
|
|
424
|
+
const decisions = this.json.loadAll("decisions");
|
|
425
|
+
// decisions tied to an active blocking constraint via source_decision
|
|
426
|
+
const blockingDec = new Set(this.json.loadAll("constraints")
|
|
427
|
+
.filter((c) => c.severity === "blocking" && c.status !== "retired" && c.source_decision)
|
|
428
|
+
.map((c) => c.source_decision));
|
|
429
|
+
const out = [];
|
|
430
|
+
const seen = new Set(); // dedup by kind+name: report each resurrected item once
|
|
431
|
+
const add = (d, kind, name) => {
|
|
432
|
+
const key = `${kind}:${name}`;
|
|
433
|
+
if (seen.has(key))
|
|
434
|
+
return;
|
|
435
|
+
seen.add(key);
|
|
436
|
+
out.push({ decision: d.id, title: d.title, kind, name, blocking: blockingDec.has(d.id), reason: d.decision || d.title });
|
|
437
|
+
};
|
|
438
|
+
// Blocking-linked decisions first, so a deduped hit keeps the higher-severity
|
|
439
|
+
// attribution (the strict guard fails on `blocking`).
|
|
440
|
+
const ordered = [...decisions].sort((a, b) => Number(blockingDec.has(b.id)) - Number(blockingDec.has(a.id)));
|
|
441
|
+
for (const d of ordered) {
|
|
442
|
+
// Only IN-FORCE decisions: re-adding what an OUTDATED (superseded) decision
|
|
443
|
+
// removed is not a regression against the current design.
|
|
444
|
+
if (d.superseded_by || d.status === "superseded")
|
|
445
|
+
continue;
|
|
446
|
+
if (!d.retired.symbols.length && !d.retired.deps.length)
|
|
447
|
+
continue;
|
|
448
|
+
if (!fileRelevant(d.related_files))
|
|
449
|
+
continue;
|
|
450
|
+
for (const s of d.retired.symbols)
|
|
451
|
+
if (addedSyms.has(s))
|
|
452
|
+
add(d, "symbol", s);
|
|
453
|
+
for (const dep of d.retired.deps)
|
|
454
|
+
if (addedDeps.has(dep))
|
|
455
|
+
add(d, "dep", dep);
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
/** The symbols/deps an in-force decision deliberately RETIRED from a file — the
|
|
460
|
+
* agent-hook grounding ("don't re-add X here; dec_Y removed it"). No diff is
|
|
461
|
+
* available at edit time, so this surfaces the risk as context, not a block. */
|
|
462
|
+
retiredForFile(file) {
|
|
463
|
+
const out = [];
|
|
464
|
+
for (const d of this.json.loadAll("decisions")) {
|
|
465
|
+
if (d.superseded_by || d.status === "superseded")
|
|
466
|
+
continue;
|
|
467
|
+
if (!d.retired.symbols.length && !d.retired.deps.length)
|
|
468
|
+
continue;
|
|
469
|
+
if (!d.related_files.some((f) => pathRelated(f, file)))
|
|
470
|
+
continue;
|
|
471
|
+
out.push({ decision: d.id, title: d.title, symbols: d.retired.symbols, deps: d.retired.deps });
|
|
472
|
+
}
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
367
475
|
/** Bugs matching a symptom (FTS over bugs) or a symbol, with lineage (hunch_bug_lineage). */
|
|
368
476
|
bugLineage(symptomOrSymbol) {
|
|
369
477
|
const bugs = this.json.loadAll("bugs");
|
|
@@ -450,8 +558,8 @@ export class HunchStore {
|
|
|
450
558
|
/** The Context Assembler (DESIGN §2.1/§6): the MINIMAL relevant Hunch slice for
|
|
451
559
|
* a task on `target`, ordered by what matters most — invariants first, then the
|
|
452
560
|
* why, then blast radius and bug history — trimmed to a rough token budget. */
|
|
453
|
-
assembleContext(target, budget = 1500) {
|
|
454
|
-
const w = this.why(target);
|
|
561
|
+
assembleContext(target, budget = 1500, opts = {}) {
|
|
562
|
+
const w = this.why(target, opts);
|
|
455
563
|
const symIds = w.symbols.map((s) => s.id);
|
|
456
564
|
const blast = new Map();
|
|
457
565
|
for (const id of symIds) {
|
|
@@ -477,6 +585,25 @@ export class HunchStore {
|
|
|
477
585
|
function sev(s) {
|
|
478
586
|
return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
|
|
479
587
|
}
|
|
588
|
+
/** Is a valid-time window open at `asOf`? `valid_from` undefined = always-started
|
|
589
|
+
* (legacy records). `valid_to` null = still in force. `asOf` undefined disables
|
|
590
|
+
* filtering (the history-inclusive default). Half-open [from, to) so a record and
|
|
591
|
+
* the one that supersedes it never both match at the supersession instant. */
|
|
592
|
+
/** Do two repo paths refer to the same file? Exact match, or one is a trailing
|
|
593
|
+
* path-SEGMENT suffix of the other (e.g. "x.ts" vs "src/x.ts") — anchored at a
|
|
594
|
+
* "/" boundary so "re.ts" never matches "store.ts" (the bare-endsWith hazard). */
|
|
595
|
+
function pathRelated(a, b) {
|
|
596
|
+
return a === b || a.endsWith("/" + b) || b.endsWith("/" + a);
|
|
597
|
+
}
|
|
598
|
+
function inWindow(valid_from, valid_to, asOf) {
|
|
599
|
+
if (!asOf)
|
|
600
|
+
return true;
|
|
601
|
+
if (valid_from && valid_from > asOf)
|
|
602
|
+
return false;
|
|
603
|
+
if (valid_to != null && asOf >= valid_to)
|
|
604
|
+
return false;
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
480
607
|
function round(n) {
|
|
481
608
|
return Math.round(n * 100) / 100;
|
|
482
609
|
}
|
|
@@ -93,8 +93,17 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
93
93
|
related_components: relatedComponents,
|
|
94
94
|
related_files: codeFiles,
|
|
95
95
|
supersedes: existing?.supersedes ?? null,
|
|
96
|
+
superseded_by: existing?.superseded_by ?? null,
|
|
96
97
|
caused_by_bug: existing?.caused_by_bug ?? null,
|
|
97
98
|
commit: meta.shortSha,
|
|
99
|
+
// Valid-time window is git-anchored: the decision takes effect at its commit
|
|
100
|
+
// date and stays in force until a later decision supersedes it (preserve any
|
|
101
|
+
// window an earlier sync/supersession already set on this same commit's record).
|
|
102
|
+
valid_from: existing?.valid_from ?? meta.date,
|
|
103
|
+
valid_to: existing?.valid_to ?? null,
|
|
104
|
+
// What this commit DELETED — the Regression Guard later matches a re-adding
|
|
105
|
+
// diff against this (recompute from the fresh analysis, even on --force).
|
|
106
|
+
retired: { symbols: analysis.removedSymbols.map((s) => s.name), deps: analysis.removedDeps },
|
|
98
107
|
provenance: {
|
|
99
108
|
source: draft.source,
|
|
100
109
|
confidence: draft.confidence,
|
|
@@ -227,6 +236,9 @@ function promoteConstraint(store, bug) {
|
|
|
227
236
|
rationale: `Derived from ${bug.id}: ${bug.root_cause || bug.symptom}`,
|
|
228
237
|
source_decision: null,
|
|
229
238
|
violations: [],
|
|
239
|
+
status: "active",
|
|
240
|
+
valid_from: new Date().toISOString(),
|
|
241
|
+
valid_to: null,
|
|
230
242
|
provenance: { source: "derived", confidence: Math.min(0.9, bug.provenance.confidence + 0.2), evidence: [`bug:${bug.id}`] },
|
|
231
243
|
};
|
|
232
244
|
return store.json.put("constraints", con);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",
|