@davesheffer/hunch 1.1.1 → 1.2.2
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/README.md +12 -0
- package/dist/cli/index.js +127 -1
- package/dist/cli/preflight.js +19 -0
- package/dist/core/docscan.js +110 -0
- package/dist/core/drift.js +16 -34
- package/dist/core/format.js +46 -0
- package/dist/extractors/git.js +19 -5
- package/dist/extractors/indexer.js +23 -1
- package/dist/integrations/claudemd.js +8 -2
- package/dist/integrations/providers.js +4 -4
- package/dist/integrations/sync.js +9 -9
- package/dist/mcp/server.js +20 -5
- package/dist/store/hunchStore.js +64 -0
- package/dist/synthesis/provider.js +9 -0
- package/dist/wiki/adopt.js +97 -0
- package/dist/wiki/wiki.js +605 -0
- package/package.json +1 -1
package/dist/mcp/server.js
CHANGED
|
@@ -19,7 +19,7 @@ import { refreshExistingGrounding } from "../integrations/providers.js";
|
|
|
19
19
|
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, pullHunch } from "../extractors/git.js";
|
|
20
20
|
import { flushCapture } from "../integrations/sync.js";
|
|
21
21
|
import { ensureTeamOverlay } from "../integrations/team.js";
|
|
22
|
-
import { formatContext } from "../core/format.js";
|
|
22
|
+
import { formatContext, formatStructure } from "../core/format.js";
|
|
23
23
|
import { compareCandidates } from "../core/compare.js";
|
|
24
24
|
import { checkConformance } from "../core/conformance.js";
|
|
25
25
|
import { renderMarkdown, renderImpact, verdict } from "../core/checkreport.js";
|
|
@@ -29,6 +29,15 @@ import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken }
|
|
|
29
29
|
import { randomUUID } from "node:crypto";
|
|
30
30
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
31
31
|
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
32
|
+
/** Honest auto-commit suffix: reports only what flushCapture ACTUALLY did. A skipped
|
|
33
|
+
* commit (backstop/lock/nothing staged) says nothing — the record is on disk and the
|
|
34
|
+
* next flush sweeps it up; claiming "auto-committed" there would be a lie. */
|
|
35
|
+
const flushNote = (flush, home, mode) => flush === "pushed" ? ` (committed + pushed to the ${mode === "shared" ? "shared team store" : "private repo"})`
|
|
36
|
+
: flush === "committed"
|
|
37
|
+
? home === "private"
|
|
38
|
+
? " (committed to the overlay repo — push deferred: offline, no upstream, or merge conflict; the next capture or `hunch private --sync` retries)"
|
|
39
|
+
: " (auto-committed to .hunch/ — rides your next push)"
|
|
40
|
+
: "";
|
|
32
41
|
// Read-side token budgets: every tool result is injected into a Claude Code
|
|
33
42
|
// session, so an uncapped list pollutes the context window. Cap each list to its
|
|
34
43
|
// highest-signal head (records are pre-sorted by severity/confidence) and tell the
|
|
@@ -422,8 +431,7 @@ export function buildServer(root) {
|
|
|
422
431
|
// record commits+pushes its overlay repo; a public one commits .hunch/ in THIS repo
|
|
423
432
|
// (commit only — it rides the user's next push, never auto-pushing their code branch).
|
|
424
433
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`);
|
|
425
|
-
const flushed = flush
|
|
426
|
-
: flush === "committed" ? " (auto-committed to .hunch/ — rides your next push)" : "";
|
|
434
|
+
const flushed = flushNote(flush, home, store.mode);
|
|
427
435
|
// Capture-session gate (staged deprecation, §9.3): a token proves an interview
|
|
428
436
|
// preceded the write. No token still writes (non-breaking), but returns a nudge
|
|
429
437
|
// toward /capture so the un-interviewed bypass is visible, not silent. A token
|
|
@@ -480,8 +488,7 @@ export function buildServer(root) {
|
|
|
480
488
|
if (home === "public")
|
|
481
489
|
refreshExistingGrounding(root, store); // overlay rules never render into committed grounding
|
|
482
490
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!input.private, `hunch: capture ${rec.id}`);
|
|
483
|
-
const flushed = flush
|
|
484
|
-
: flush === "committed" ? " (auto-committed to .hunch/ — rides your next push)" : "";
|
|
491
|
+
const flushed = flushNote(flush, home, store.mode);
|
|
485
492
|
const enforce = rec.severity === "blocking"
|
|
486
493
|
? "blocks a DIRECT edit to its scope at strict firmness, and fails a PR whose diff touches that scope (CI guard); blast-radius hits and lower firmness stay advisory"
|
|
487
494
|
: "flags violating edits and PRs (advisory)";
|
|
@@ -528,6 +535,14 @@ export function buildServer(root) {
|
|
|
528
535
|
return err(`Failed to compute merge verdict: ${e.message}`);
|
|
529
536
|
}
|
|
530
537
|
});
|
|
538
|
+
// -- hunch_structure (graph-served orientation — the anti-grep) ------------
|
|
539
|
+
server.registerTool("hunch_structure", {
|
|
540
|
+
title: "The indexed shape of the repo / a dir / a file / a symbol",
|
|
541
|
+
description: "Orient WITHOUT grep/glob rounds: the graph already holds the repo's structure. No target → repo map (components + directories by symbol weight). A directory → its files with their symbols. A file → its outline (symbols, fan-in/out, callers). An exact symbol name → its definition site(s) with one-hop neighbors. Call this FIRST when exploring unfamiliar code — it tells you exactly which file to read, instead of searching for it.",
|
|
542
|
+
inputSchema: {
|
|
543
|
+
target: z.string().optional().describe("A directory, file path, or exact symbol name. Omit for the repo map."),
|
|
544
|
+
},
|
|
545
|
+
}, async ({ target }) => ok(formatStructure(store.structure(target))));
|
|
531
546
|
// -- hunch_pr_impact (read-only impact surface — advisory, never gates) ----
|
|
532
547
|
server.registerTool("hunch_pr_impact", {
|
|
533
548
|
title: "PR impact: the dependency + memory surface of a change",
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -705,6 +705,70 @@ export class HunchStore {
|
|
|
705
705
|
decisions: [...decisions.values()],
|
|
706
706
|
};
|
|
707
707
|
}
|
|
708
|
+
/** Structure view (hunch_structure / hunch structure): serve the indexed shape of
|
|
709
|
+
* the repo so an agent ORIENTS from the graph instead of running grep/glob rounds.
|
|
710
|
+
* Resolution: no target -> repo map; a directory -> its files+symbols; a file ->
|
|
711
|
+
* its outline; a symbol name -> exact definition site(s) with one-hop neighbors.
|
|
712
|
+
* Deterministic, read-only, straight from the derived index. */
|
|
713
|
+
structure(target) {
|
|
714
|
+
if (!target || !target.trim()) {
|
|
715
|
+
const components = this.recs("components").map((c) => ({ id: c.id, name: c.name, responsibility: c.responsibility, paths: c.paths }));
|
|
716
|
+
const rows = this.db.prepare(`SELECT file, count(*) AS n FROM symbols GROUP BY file`).all();
|
|
717
|
+
const dirs = new Map();
|
|
718
|
+
for (const r of rows) {
|
|
719
|
+
const dir = r.file.includes("/") ? r.file.slice(0, r.file.lastIndexOf("/")) : ".";
|
|
720
|
+
const e = dirs.get(dir) ?? { files: 0, symbols: 0 };
|
|
721
|
+
e.files++;
|
|
722
|
+
e.symbols += r.n;
|
|
723
|
+
dirs.set(dir, e);
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
kind: "repo",
|
|
727
|
+
components,
|
|
728
|
+
dirs: [...dirs.entries()].map(([dir, v]) => ({ dir, ...v })).sort((a, b) => b.symbols - a.symbols),
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
const t = toPosixTarget(target.trim()).replace(/\/+$/, "");
|
|
732
|
+
// FILE: exact path or unique suffix
|
|
733
|
+
const fileRows = this.db.prepare(`SELECT id, name, kind, loc, fan_in, fan_out FROM symbols WHERE file = ? ORDER BY fan_in DESC, name`).all(t);
|
|
734
|
+
const fileHit = fileRows.length ? t : this.db.prepare(`SELECT DISTINCT file FROM symbols WHERE file LIKE ?`).all(`%/${t}`).map((r) => r.file);
|
|
735
|
+
const file = typeof fileHit === "string" ? fileHit : fileHit.length === 1 ? fileHit[0] : null;
|
|
736
|
+
if (file) {
|
|
737
|
+
const syms = fileRows.length ? fileRows : this.db.prepare(`SELECT id, name, kind, loc, fan_in, fan_out FROM symbols WHERE file = ? ORDER BY fan_in DESC, name`).all(file);
|
|
738
|
+
return {
|
|
739
|
+
kind: "file",
|
|
740
|
+
file,
|
|
741
|
+
symbols: syms.map((r) => ({ ...r, callers: this.edgeNeighbors(r.id, "in", 5) })),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
// DIRECTORY: any indexed file under the prefix
|
|
745
|
+
const dirFiles = this.db.prepare(`SELECT file, name, kind, fan_in FROM symbols WHERE file LIKE ? ORDER BY file, fan_in DESC`).all(`${t}/%`);
|
|
746
|
+
if (dirFiles.length) {
|
|
747
|
+
const byFile = new Map();
|
|
748
|
+
for (const r of dirFiles) {
|
|
749
|
+
const list = byFile.get(r.file) ?? [];
|
|
750
|
+
list.push({ name: r.name, kind: r.kind, fan_in: r.fan_in });
|
|
751
|
+
byFile.set(r.file, list);
|
|
752
|
+
}
|
|
753
|
+
return { kind: "dir", dir: t, files: [...byFile.entries()].map(([f, symbols]) => ({ file: f, symbols })) };
|
|
754
|
+
}
|
|
755
|
+
// SYMBOL: exact name
|
|
756
|
+
const named = this.db.prepare(`SELECT id, name, kind, file, fan_in, fan_out FROM symbols WHERE name = ? LIMIT 10`).all(t);
|
|
757
|
+
if (named.length) {
|
|
758
|
+
return {
|
|
759
|
+
kind: "symbol",
|
|
760
|
+
matches: named.map((m) => ({ ...m, callers: this.edgeNeighbors(m.id, "in", 6), callees: this.edgeNeighbors(m.id, "out", 6) })),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
return { kind: "none", target: t };
|
|
764
|
+
}
|
|
765
|
+
/** Labelled one-hop edge neighbors of a node ("in" = who reaches it, "out" = what it reaches). */
|
|
766
|
+
edgeNeighbors(id, dir, limit) {
|
|
767
|
+
const sql = dir === "in"
|
|
768
|
+
? `SELECT e."from" AS nb FROM edges e WHERE e."to" = ? AND e.type IN ('calls','depends_on','imports','contains') LIMIT ?`
|
|
769
|
+
: `SELECT e."to" AS nb FROM edges e WHERE e."from" = ? AND e.type IN ('calls','depends_on','imports','contains') LIMIT ?`;
|
|
770
|
+
return this.db.prepare(sql).all(id, limit).map((r) => this.nodeLabel(r.nb));
|
|
771
|
+
}
|
|
708
772
|
/** Constraints whose scope glob matches a path/glob (hunch_check_constraints).
|
|
709
773
|
* By default only ACTIVE invariants are returned — a retired constraint is no
|
|
710
774
|
* longer enforced. Pass `{ asOf }` to instead return the invariants in force at
|
|
@@ -197,6 +197,15 @@ class CliSynthProvider {
|
|
|
197
197
|
throw new Error(`${this.name}: no usable bug JSON in output`);
|
|
198
198
|
return draft;
|
|
199
199
|
}
|
|
200
|
+
/** Grounded prose for the wiki. Same subscription-only run() path (API keys
|
|
201
|
+
* stripped). Throws on empty output so the caller falls back to its
|
|
202
|
+
* deterministic template page. */
|
|
203
|
+
async draftProse(prompt) {
|
|
204
|
+
const text = (await this.run(prompt)).trim();
|
|
205
|
+
if (!text)
|
|
206
|
+
throw new Error(`${this.name}: empty prose output`);
|
|
207
|
+
return text;
|
|
208
|
+
}
|
|
200
209
|
/** The Critic pass: audit a draft against its commit. Same subscription-only
|
|
201
210
|
* run() path (API keys stripped), so this never bills the pay-per-token API.
|
|
202
211
|
* Throws on unusable output so verifyDecisionSafe degrades to the un-audited
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doc adoption — the wiki TAKES OVER a stale doc. When the specs ledger grades
|
|
3
|
+
* a doc "stale" (do-not-trust), `hunch wiki` copies it into `<wiki>/docs/` as a
|
|
4
|
+
* WIKI-MANAGED page and heals the COPY against the graph:
|
|
5
|
+
*
|
|
6
|
+
* - every pin to a superseded decision is RE-PINNED to the current one,
|
|
7
|
+
* - a "graph correction" callout is injected right after each healed pin,
|
|
8
|
+
* quoting the current decision (and what it rejected) so the stale prose
|
|
9
|
+
* below it can be read safely,
|
|
10
|
+
* - non-pin issues (proposed-but-shipped, dead code refs) surface in a
|
|
11
|
+
* banner at the top.
|
|
12
|
+
*
|
|
13
|
+
* The ORIGINAL file is never touched — "Hunch never rewrites prose" applies to
|
|
14
|
+
* the user's files; the adopted copy is a generated artifact (hunch:wiki
|
|
15
|
+
* header), so healing it is regeneration, not prose-rewriting. From adoption
|
|
16
|
+
* on, the ledger and component pages route readers to the wiki copy; the copy
|
|
17
|
+
* is freshness-hashed over its INPUTS (source content + the current decision
|
|
18
|
+
* per pinned topic + issues), so a source edit or a graph move re-heals it,
|
|
19
|
+
* and a source doc that becomes grounded again (human healed the original) or
|
|
20
|
+
* disappears retires the copy automatically.
|
|
21
|
+
*
|
|
22
|
+
* Deterministic by construction: same source + same graph → byte-identical
|
|
23
|
+
* copy. No LLM anywhere in this path.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { parseDocAnchors } from "../core/docanchors.js";
|
|
27
|
+
import { currentForTopic, rejectedForTopic } from "../core/topics.js";
|
|
28
|
+
/** Page slug for an adopted doc: full rel path, kebab-cased ("docs/api-v2.md" →
|
|
29
|
+
* "docs-api-v2"). Kebab-casing can collide across DIFFERENT rels ("docs/api-v2.md"
|
|
30
|
+
* vs "docs-api/v2.md"), so a `taken` set disambiguates with a short content hash
|
|
31
|
+
* of the rel — deterministic, and wikiStatus is the single caller that assigns
|
|
32
|
+
* slugs (renderers receive the resulting paths; they never re-derive). */
|
|
33
|
+
export function adoptedSlug(rel, taken) {
|
|
34
|
+
let slug = rel.replace(/\.md$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "doc";
|
|
35
|
+
if (taken.has(slug))
|
|
36
|
+
slug = `${slug}-${createHash("sha256").update(rel).digest("hex").slice(0, 6)}`;
|
|
37
|
+
taken.add(slug);
|
|
38
|
+
return slug;
|
|
39
|
+
}
|
|
40
|
+
const clip = (s, n) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s);
|
|
41
|
+
/** The freshness hash of an adopted copy: source content + what the graph
|
|
42
|
+
* currently says for each pinned topic (everything the renderer quotes,
|
|
43
|
+
* including rejected alternatives) + the grade's issues. Any of these moving
|
|
44
|
+
* re-heals the copy; the copy's own bytes are never an input. */
|
|
45
|
+
export function adoptionHash(content, decisions, doc) {
|
|
46
|
+
const topical = doc.topics.map((t) => {
|
|
47
|
+
const cur = currentForTopic(decisions, t);
|
|
48
|
+
return {
|
|
49
|
+
topic: t,
|
|
50
|
+
current: cur ? { id: cur.id, decision: cur.decision, title: cur.title } : null,
|
|
51
|
+
rejected: rejectedForTopic(decisions, t),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
return createHash("sha256")
|
|
55
|
+
.update(JSON.stringify({ content, topical, issues: doc.issues }))
|
|
56
|
+
.digest("hex").slice(0, 16);
|
|
57
|
+
}
|
|
58
|
+
/** Render the wiki-managed copy of a stale doc, healed against the graph. */
|
|
59
|
+
export function renderAdoptedDoc(doc, content, decisions) {
|
|
60
|
+
const byId = new Map(decisions.map((d) => [d.id, d]));
|
|
61
|
+
const lines = content.split("\n");
|
|
62
|
+
const out = [];
|
|
63
|
+
out.push(`<!-- hunch:wiki doc:${doc.rel} — ADOPTED wiki-managed copy of \`${doc.rel}\`, healed against the graph by \`hunch wiki\`. The original graded stale and is preserved untouched; treat THIS page as the readable version. Do not edit by hand — edit the graph (or heal the original), then \`hunch wiki --heal\`. -->`);
|
|
64
|
+
out.push(`> ⚠ **Wiki-managed copy.** The original \`${doc.rel}\` no longer matches the decision graph and was adopted here. Pins below are healed to CURRENT decisions; corrections are inline. Heal the original to release it back (the copy retires automatically).`);
|
|
65
|
+
// Every grading issue is listed — a dangling pin with NO current successor gets
|
|
66
|
+
// no inline correction below, so the banner is its only visible explanation.
|
|
67
|
+
for (const i of doc.issues)
|
|
68
|
+
out.push(`> - ${i}`);
|
|
69
|
+
out.push("");
|
|
70
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
71
|
+
for (const raw of lines) {
|
|
72
|
+
// Heal EVERY stale pin on the line, and only inside its own marker — a bare
|
|
73
|
+
// decision id in the surrounding prose must never be rewritten.
|
|
74
|
+
let line = raw;
|
|
75
|
+
const corrections = [];
|
|
76
|
+
for (const a of parseDocAnchors(raw)) {
|
|
77
|
+
if (!a.pin)
|
|
78
|
+
continue;
|
|
79
|
+
const pinned = byId.get(a.pin);
|
|
80
|
+
const current = currentForTopic(decisions, a.topic);
|
|
81
|
+
const pinIsStale = (!pinned || pinned.status === "superseded" || !!pinned.superseded_by) && current && current.id !== a.pin;
|
|
82
|
+
if (!pinIsStale || !current)
|
|
83
|
+
continue;
|
|
84
|
+
line = line.replace(new RegExp(`(hunch:topic\\s+${escapeRe(a.topic)}\\s+)${escapeRe(a.pin)}(?![A-Za-z0-9])`), `$1${current.id}`);
|
|
85
|
+
corrections.push(`> **🧭 Graph correction** — this section was written against ${pinned ? `superseded \`${a.pin}\`` : `\`${a.pin}\`, which no longer exists`}. Current decision for \`${a.topic}\` is \`${current.id}\` — **${current.title}**: ${clip(current.decision, 400)}`);
|
|
86
|
+
const rejected = rejectedForTopic(decisions, a.topic);
|
|
87
|
+
if (rejected.length)
|
|
88
|
+
corrections.push(`> Rejected along the way: ${rejected.slice(0, 3).map((r) => clip(r, 120)).join("; ")}`);
|
|
89
|
+
}
|
|
90
|
+
out.push(line);
|
|
91
|
+
if (corrections.length)
|
|
92
|
+
out.push(...corrections, "");
|
|
93
|
+
}
|
|
94
|
+
out.push("", "---", "", `_Adopted from \`${doc.rel}\` — a derived, wiki-managed copy. Regenerate: \`hunch wiki --heal\`._`, "");
|
|
95
|
+
return out.join("\n");
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=adopt.js.map
|