@davesheffer/hunch 0.8.0 → 0.10.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 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), `hunch_record_decision` (write-back).
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
 
@@ -122,6 +124,7 @@ they all consult the same memory:
122
124
  | Cursor | `.cursor/mcp.json` | `.cursor/rules/hunch.mdc` (always-applied) |
123
125
  | VS Code (Copilot) | `.vscode/mcp.json` | `.github/copilot-instructions.md` |
124
126
  | Codex CLI | `.codex/config.toml` | `AGENTS.md` |
127
+ | Windsurf | `.windsurf/mcp_config.json` | `.windsurf/rules/hunch.md` (always-on) |
125
128
  | Anything else | — | `AGENTS.md` (cross-tool standard) |
126
129
 
127
130
  Each writer **merges** into existing files (other MCP servers and your own prose are
@@ -131,7 +134,7 @@ preserved) and is idempotent. Opt out with `hunch init --no-providers`.
131
134
 
132
135
  | Command | What |
133
136
  |---|---|
134
- | `hunch init` | scaffold `.hunch/`, index, install hook + merge driver, auto-install the advisory pre-commit guard, install the **Claude Code agent hooks**, and wire up **every assistant** (Claude Code, Cursor, VS Code/Copilot, Codex, AGENTS.md). Flags: `--no-enforce`, `--enforce-strict`, `--no-providers`, `--no-agent-hooks`, `--firmness <level>` |
137
+ | `hunch init` | scaffold `.hunch/`, index, install hook + merge driver, auto-install the advisory pre-commit guard, install the **Claude Code agent hooks**, and wire up **every assistant** (Claude Code, Cursor, VS Code/Copilot, Codex, Windsurf, AGENTS.md). Flags: `--no-enforce`, `--enforce-strict`, `--no-providers`, `--no-agent-hooks`, `--firmness <level>` |
135
138
  | `hunch index` | parse repo → symbols / edges / components (deterministic, no LLM) |
136
139
  | `hunch backfill --since 90d` | replay git history → seed decisions |
137
140
  | `hunch sync [sha]` | turn a commit into a Decision (run automatically by the hook) |
@@ -139,12 +142,14 @@ preserved) and is idempotent. Opt out with `hunch init --no-providers`.
139
142
  | `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
143
  | `hunch firmness [off\|advisory\|firm\|strict]` | get/set how firmly the agent hook enforces Hunch before edits (no arg prints the current level) |
141
144
  | `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>` | decisions / bugs / constraints explaining a target (flags `⚠STALE`) |
145
+ | `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 |
146
+ | `hunch timeline <path\|symbol>` | the decision history for a target — what was believed, its valid-time window, and what superseded it |
147
+ | `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
148
  | `hunch query "<q>" [--semantic]` | full-text + graph search (`--semantic` blends in local embeddings) |
144
149
  | `hunch embed` | generate local embeddings for semantic recall (opt-in; needs `@huggingface/transformers`) |
145
- | `hunch context <path\|symbol>` | minimal relevant slice for a task: invariants → decisions → bugs → blast radius |
150
+ | `hunch context <path\|symbol> [--as-of <ref>]` | minimal relevant slice for a task: invariants → decisions → bugs → blast radius (`--as-of` time-travels) |
146
151
  | `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 |
152
+ | `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
153
  | `hunch stale [--resync]` | drift: records whose files changed after last verification (`--resync` regenerates stale decisions from their commits) |
149
154
  | `hunch review [--accept <id>\|--reject <id>]` | curate: triage / promote / drop low-confidence drafts |
150
155
  | `hunch migrate` | upgrade `.hunch/` records to the current schema version |
@@ -177,6 +182,12 @@ hunch firmness strict # change it (takes effect on the next edit; no restart
177
182
  | `firm` | advisory **+** explicitly flag invariants in the file's scope |
178
183
  | `strict` | firm **+** **deny** an edit that hits a *blocking* invariant (directly or via blast radius), feeding the invariant back as the refusal reason |
179
184
 
185
+ Before an edit, the hook also grounds the agent in anything an in-force decision
186
+ **deliberately retired** from that file ("don't re-introduce `login` here — dec_017 removed
187
+ it"). The actual gate is at commit time: `hunch check` runs the **Regression Guard** over
188
+ the staged diff and, under `--strict`, fails the commit when a change re-adds a retired
189
+ symbol/dependency tied to a blocking invariant (otherwise it warns).
190
+
180
191
  The hook never breaks your flow: any error or unrecognized input emits nothing and exits
181
192
  0, and it stays silent on files Hunch hasn't learned yet. `strict` only bites once you have
182
193
  **blocking** constraints recorded (`hunch record-constraint … --severity blocking`) — with
@@ -330,5 +341,5 @@ npm test # node:test suite (store, graph, parse, indexer, synthesis,
330
341
  npm run hunch -- why src/store/hunchStore.ts # run the CLI from source via tsx, no build
331
342
  ```
332
343
 
333
- See [DESIGN.md](DESIGN.md) for the full spec. Deferred by design: embeddings / vector
334
- search, PR/CI webhooks, a web dashboard, and multi-repo support.
344
+ See [DESIGN.md](DESIGN.md) for the full spec. Deferred by design: PR/CI webhooks, a
345
+ 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
- .action((target) => {
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 w = store.why(target);
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 (opts.strict && blocking) {
573
- console.log(`\n ${blocking} blocking invariant(s) in scope (direct or near) — review before committing.`);
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
- const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length;
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 {
@@ -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 = 1;
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) {
@@ -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. */
@@ -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. */
@@ -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) {
@@ -11,6 +11,7 @@
11
11
  * Cursor | .cursor/mcp.json | mcpServers | .cursor/rules/hunch.mdc
12
12
  * VS Code | .vscode/mcp.json | servers (+type)| .github/copilot-instructions.md
13
13
  * Codex CLI | .codex/config.toml | [mcp_servers.*]| AGENTS.md
14
+ * Windsurf | .windsurf/mcp_config.json| mcpServers | .windsurf/rules/hunch.md
14
15
  * (any other) | — | — | AGENTS.md (cross-tool standard)
15
16
  *
16
17
  * Every writer MERGES into existing files (preserving other servers / user prose)
@@ -206,6 +207,25 @@ export function writeCursorRule(root, store) {
206
207
  writeFileSync(file, body);
207
208
  return file;
208
209
  }
210
+ /** Windsurf (Cascade): .windsurf/mcp_config.json — same `mcpServers` shape as
211
+ * Cursor. Repo-local (committed, shared via git) to match Hunch's other configs,
212
+ * rather than the global ~/.codeium/windsurf path. Merges; refuses to clobber. */
213
+ export function writeWindsurfMcp(root, inv) {
214
+ const file = join(root, ".windsurf", "mcp_config.json");
215
+ const json = readJsonObj(file);
216
+ json.mcpServers = json.mcpServers ?? {};
217
+ json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
218
+ return writeJson(file, json);
219
+ }
220
+ /** Windsurf project rule (.windsurf/rules/hunch.md). `trigger: always_on` keeps the
221
+ * Hunch grounding in Cascade's context for every request. Fully managed (overwritten). */
222
+ export function writeWindsurfRule(root, store) {
223
+ const file = join(root, ".windsurf", "rules", "hunch.md");
224
+ const body = `---\ntrigger: always_on\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\n---\n\n${renderHunchSection(store)}\n`;
225
+ mkdirSync(dirname(file), { recursive: true });
226
+ writeFileSync(file, body);
227
+ return file;
228
+ }
209
229
  /** Scaffold MCP config + grounding for all supported assistants. Returns a
210
230
  * per-assistant summary for `hunch init` to print. Each assistant is isolated:
211
231
  * a writer that refuses to clobber a malformed file degrades to a warning rather
@@ -215,6 +235,7 @@ export function scaffoldProviders(root, inv, store) {
215
235
  ["Cursor", () => [writeCursorMcp(root, inv), writeCursorRule(root, store)]],
216
236
  ["VS Code (Copilot)", () => [writeVscodeMcp(root, inv), writeCopilotInstructions(root, store)]],
217
237
  ["Codex CLI", () => [writeCodexConfig(root, inv)]],
238
+ ["Windsurf", () => [writeWindsurfMcp(root, inv), writeWindsurfRule(root, store)]],
218
239
  ["Any (AGENTS.md)", () => [writeAgentsMd(root, store)]],
219
240
  ];
220
241
  return tasks.map(([assistant, run]) => {
@@ -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: { target: z.string().describe("A file path (e.g. src/auth/session.ts) or symbol name.") },
81
- }, async ({ target }) => {
82
- const w = store.why(target);
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
- return ok(formatContext(store.assembleContext(target, budget_tokens ?? 1500)));
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: new Date().toISOString(),
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}`);
@@ -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
- why(target) {
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
- checkConstraints(scope) {
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.8.0",
3
+ "version": "0.10.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.",