@davesheffer/hunch 0.38.3 → 0.40.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/README.md +55 -3
- package/dist/cli/index.js +189 -40
- package/dist/core/capturetoken.js +36 -0
- package/dist/core/drift.js +27 -0
- package/dist/core/topics.js +81 -0
- package/dist/core/types.js +7 -0
- package/dist/extractors/git.js +27 -5
- package/dist/integrations/scaffold.js +24 -0
- package/dist/integrations/sync.js +18 -0
- package/dist/integrations/team.js +112 -0
- package/dist/integrations/worktree.js +6 -4
- package/dist/mcp/server.js +128 -26
- package/dist/store/hunchStore.js +72 -6
- package/dist/synthesis/synthesize.js +11 -11
- package/package.json +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** A decision is "live" for a topic when it is the accepted, non-superseded,
|
|
2
|
+
* still-in-force entry: the status gate plus both closure links open. Matches the
|
|
3
|
+
* in-force predicate used across the veto/regression guards. */
|
|
4
|
+
export function isLive(d) {
|
|
5
|
+
return d.status === "accepted" && d.superseded_by === null && d.valid_to === null;
|
|
6
|
+
}
|
|
7
|
+
/** Every live decision anchored to `topic`. In a healthy graph this is length 0 or 1;
|
|
8
|
+
* length > 1 is a topic collision the §4 resolution must settle. */
|
|
9
|
+
export function liveForTopic(decisions, topic) {
|
|
10
|
+
return decisions.filter((d) => d.topic === topic && isLive(d));
|
|
11
|
+
}
|
|
12
|
+
/** current(topic): the single live decision for a topic, or null. Null when there is
|
|
13
|
+
* none — AND when the topic is in an unresolved collision (>1 live), because an
|
|
14
|
+
* ambiguous current must never be injected as authoritative truth. */
|
|
15
|
+
export function currentForTopic(decisions, topic) {
|
|
16
|
+
const live = liveForTopic(decisions, topic);
|
|
17
|
+
return live.length === 1 ? live[0] : null;
|
|
18
|
+
}
|
|
19
|
+
/** history(topic): the full chain for a topic, newest first (by effect-time). */
|
|
20
|
+
export function historyForTopic(decisions, topic) {
|
|
21
|
+
return decisions
|
|
22
|
+
.filter((d) => d.topic === topic)
|
|
23
|
+
.sort((a, b) => (b.valid_from ?? b.date).localeCompare(a.valid_from ?? a.date));
|
|
24
|
+
}
|
|
25
|
+
/** rejected(topic): the alternatives the current decision ruled out — what Veto/drift
|
|
26
|
+
* check a derived view against. Empty when there is no unambiguous current decision. */
|
|
27
|
+
export function rejectedForTopic(decisions, topic) {
|
|
28
|
+
const cur = currentForTopic(decisions, topic);
|
|
29
|
+
return cur ? [...cur.alternatives_rejected] : [];
|
|
30
|
+
}
|
|
31
|
+
/** The live decisions that would COLLIDE if an `accepted` decision `selfId` is written
|
|
32
|
+
* on `topic` while superseding `willCloseId` (or null if it supersedes nothing). The
|
|
33
|
+
* self record and the incumbent this write will actually close are excluded; anything
|
|
34
|
+
* left is a second live decision the write must not create (the capture guard refuses
|
|
35
|
+
* when this is non-empty). `willCloseId` MUST be an incumbent the write can truly close
|
|
36
|
+
* (same store) — a cross-store supersede that will no-op must be passed as null so the
|
|
37
|
+
* incumbent stays counted and the write is refused. */
|
|
38
|
+
export function captureConflicts(decisions, topic, selfId, willCloseId) {
|
|
39
|
+
return liveForTopic(decisions, topic).filter((d) => d.id !== selfId && d.id !== willCloseId);
|
|
40
|
+
}
|
|
41
|
+
/** Read-time grounding block (§3): for the topic-anchored decisions governing an edited
|
|
42
|
+
* file, state the CURRENT decision assertively ("the graph overrides any doc that says
|
|
43
|
+
* otherwise") plus what it rejected. Input is the file-scoped IN-FORCE decisions from
|
|
44
|
+
* assembleContext, so no freshness re-check is needed here — a superseded-only-anchored
|
|
45
|
+
* file is caught by the anchor-stale drift check, and the commit-time staleness gate
|
|
46
|
+
* applies the age-downgrade. Returns "" when no anchored decision governs the file. */
|
|
47
|
+
export function renderGrounding(fileDecisions) {
|
|
48
|
+
const anchored = fileDecisions.filter((d) => d.topic && isLive(d));
|
|
49
|
+
if (!anchored.length)
|
|
50
|
+
return "";
|
|
51
|
+
const lines = anchored.map((d) => {
|
|
52
|
+
const rej = d.alternatives_rejected.length ? ` (rejected: ${d.alternatives_rejected.join("; ")})` : "";
|
|
53
|
+
return `• "${d.topic}": ${d.decision || d.title} [${d.id}]${rej}`;
|
|
54
|
+
});
|
|
55
|
+
return `🧭 Hunch grounding — this file is anchored to recorded decisions; follow the graph, not a stale doc:\n${lines.join("\n")}`;
|
|
56
|
+
}
|
|
57
|
+
/** Every topic with MORE THAN ONE live decision — the invariant violations a post-merge
|
|
58
|
+
* reconcile pass surfaces for human resolution. This is the distributed half of §4
|
|
59
|
+
* Enforcement: the content merge driver merges by id and is NOT invoked for cross-file
|
|
60
|
+
* ADD/ADD, so two branches each adding an `accepted` decision for one topic land both
|
|
61
|
+
* files with no collision. This scan catches them after the merge. Keyed by topic;
|
|
62
|
+
* value is the colliding live set (length >= 2), each sorted by id for stable output. */
|
|
63
|
+
export function topicCollisions(decisions) {
|
|
64
|
+
const byTopic = new Map();
|
|
65
|
+
for (const d of decisions) {
|
|
66
|
+
if (!d.topic || !isLive(d))
|
|
67
|
+
continue;
|
|
68
|
+
const arr = byTopic.get(d.topic);
|
|
69
|
+
if (arr)
|
|
70
|
+
arr.push(d);
|
|
71
|
+
else
|
|
72
|
+
byTopic.set(d.topic, [d]);
|
|
73
|
+
}
|
|
74
|
+
const collisions = new Map();
|
|
75
|
+
for (const [topic, arr] of byTopic) {
|
|
76
|
+
if (arr.length >= 2)
|
|
77
|
+
collisions.set(topic, [...arr].sort((a, b) => a.id.localeCompare(b.id)));
|
|
78
|
+
}
|
|
79
|
+
return collisions;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=topics.js.map
|
package/dist/core/types.js
CHANGED
|
@@ -109,6 +109,13 @@ export const ConformancePredicateSchema = z.object({
|
|
|
109
109
|
export const DecisionSchema = z.object({
|
|
110
110
|
id: z.string().describe("dec_*"),
|
|
111
111
|
title: z.string(),
|
|
112
|
+
// Decision-grounding anchor: the join key that relates a doc section, a decision,
|
|
113
|
+
// and a code region for drift detection. Exactly one topic per decision; null =
|
|
114
|
+
// un-anchored (still valid, just invisible to doc≠graph detection until tagged —
|
|
115
|
+
// honest and bounded). Optional-with-default, so every legacy record validates with
|
|
116
|
+
// no migration (Zod fills null on read); grounding freshness reuses the existing
|
|
117
|
+
// valid-time / last_verified signals rather than a separate clock.
|
|
118
|
+
topic: z.string().nullable().default(null).describe("decision-grounding anchor; one topic per decision, null = un-anchored"),
|
|
112
119
|
status: z.enum(["proposed", "accepted", "rejected", "superseded"]).default("proposed"),
|
|
113
120
|
context: z.string().default(""),
|
|
114
121
|
decision: z.string().default(""),
|
package/dist/extractors/git.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Deterministic git introspection for the extractor + learning loop.
|
|
2
2
|
* No LLM here — just parsing what git already knows. */
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
-
import { isAbsolute, resolve, join } from "node:path";
|
|
4
|
+
import { isAbsolute, resolve, join, basename, dirname } from "node:path";
|
|
5
5
|
import { mkdirSync, rmSync, statSync, realpathSync } from "node:fs";
|
|
6
6
|
function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
7
7
|
// stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
|
|
@@ -21,12 +21,28 @@ function gitSafe(args, cwd, maxBuffer) {
|
|
|
21
21
|
export function isGitRepo(cwd) {
|
|
22
22
|
return gitSafe(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
|
|
23
23
|
}
|
|
24
|
+
/** The MAIN worktree's root — the stable anchor for an overlay store. A linked worktree
|
|
25
|
+
* can be `git worktree remove`d, so anything anchored inside it (an overlay clone, an
|
|
26
|
+
* absolute pointer target) silently dies for every OTHER worktree; the main checkout
|
|
27
|
+
* can't be removed. Falls back to `root` when the layout isn't the standard `.git` dir
|
|
28
|
+
* (or not a git repo), preserving today's behavior. */
|
|
29
|
+
export function mainWorktreeRoot(root) {
|
|
30
|
+
const common = gitCommonDir(root);
|
|
31
|
+
if (!common)
|
|
32
|
+
return root;
|
|
33
|
+
const abs = resolve(root, common);
|
|
34
|
+
return basename(abs) === ".git" ? dirname(abs) : root;
|
|
35
|
+
}
|
|
24
36
|
/** Best-effort: stage ONLY the hunch dir, commit, and push the repo it lives in. Shared by
|
|
25
37
|
* the post-commit auto-commit (CLI sync --commit), MCP private writes, and `hunch private
|
|
26
38
|
* --sync`. HUNCH_SYNC=1 stops the created commit from re-triggering the post-commit hook
|
|
27
39
|
* (no recursion). Stages with a pathspec scoped to `hunchDir`, so it never sweeps unrelated
|
|
28
|
-
* working-tree changes. Never throws — a non-repo dir / offline push just no-ops.
|
|
29
|
-
|
|
40
|
+
* working-tree changes. Never throws — a non-repo dir / offline push just no-ops.
|
|
41
|
+
* `push: false` commits WITHOUT merging or pushing — required when hunchDir is the PUBLIC
|
|
42
|
+
* .hunch/ inside the user's code repo: an automatic pull/push there would merge the remote
|
|
43
|
+
* into their working branch and publish their unpushed code commits. The memory commit
|
|
44
|
+
* simply rides the user's next push. */
|
|
45
|
+
export function commitAndPushHunch(hunchDir, message, opts = {}) {
|
|
30
46
|
// Serialize across worktrees: several worktrees auto-committing the SAME overlay repo
|
|
31
47
|
// at once would race git's index.lock. An atomic-mkdir lock lets one proceed; the others
|
|
32
48
|
// skip — safe because each record is already written to disk, so `git add .` here sweeps
|
|
@@ -53,7 +69,13 @@ export function commitAndPushHunch(hunchDir, message) {
|
|
|
53
69
|
execFileSync("git", ["-C", hunchDir, "reset", "-q", "--", "."], { stdio: "ignore", env });
|
|
54
70
|
}
|
|
55
71
|
catch { /* best-effort unstage */ }
|
|
56
|
-
|
|
72
|
+
// Public-store commits (push:false) skip QUIETLY: a non-memory staged set there is
|
|
73
|
+
// usually just the user's own staged work, not a misconfigured overlay — the record
|
|
74
|
+
// stays on disk and the next flush's `git add .` sweeps it up. The overlay path
|
|
75
|
+
// stays loud: there it signals the escaped-to-project-repo misconfiguration.
|
|
76
|
+
if (opts.push !== false) {
|
|
77
|
+
console.error(`hunch: refusing to auto-commit memory at "${hunchDir}" — the staged change includes deletions or non-memory files, so this is not a clean overlay repo. Nothing was committed or pushed. (Use \`hunch shared --repo <url>\` so the overlay is its OWN git repo.)`);
|
|
78
|
+
}
|
|
57
79
|
return;
|
|
58
80
|
}
|
|
59
81
|
// Only sync+push when a memory commit was actually created — never run pull/push against the
|
|
@@ -66,7 +88,7 @@ export function commitAndPushHunch(hunchDir, message) {
|
|
|
66
88
|
committed = true;
|
|
67
89
|
}
|
|
68
90
|
catch { /* nothing staged / not a repo */ }
|
|
69
|
-
if (committed && mergeRemote(hunchDir, env))
|
|
91
|
+
if (committed && opts.push !== false && mergeRemote(hunchDir, env))
|
|
70
92
|
run(["push"]);
|
|
71
93
|
}
|
|
72
94
|
finally {
|
|
@@ -55,6 +55,28 @@ then produce a **fragility report with evidence**: the specific files/functions,
|
|
|
55
55
|
the bug history behind them, their churn and fan-in, and any missing guards.
|
|
56
56
|
Avoid generic advice — every claim must cite a Hunch record or metric.
|
|
57
57
|
`;
|
|
58
|
+
const CAPTURE_CMD = `---
|
|
59
|
+
description: Capture an engineering decision into Hunch's graph via a grilling interview (topic, rationale, rejected alternatives)
|
|
60
|
+
---
|
|
61
|
+
Capture the decision for **$ARGUMENTS** into Hunch's graph.
|
|
62
|
+
|
|
63
|
+
1. Call \`hunch_capture_decision(topic?, seed?)\` — it returns the grilling protocol and a capture-session token.
|
|
64
|
+
2. Run the GRILLING LOOP: one focused question at a time. Push back on hand-wavy answers. Resolve every branch before committing — an unexamined decision poisons the graph.
|
|
65
|
+
3. Confirm the TOPIC anchor with me before committing. One topic per decision; if it spans two, split into two captures.
|
|
66
|
+
4. Capture REJECTED alternatives explicitly (what, and why not) — this is what makes the decision enforceable (Veto/drift check against it).
|
|
67
|
+
5. Commit with \`hunch_record_decision\`, passing \`capture_token\` (from step 1) and the confirmed \`topic\`. The artifact is the graph write, not prose.
|
|
68
|
+
6. On CONFLICT for the topic, do NOT auto-supersede — Hunch refuses and presents both; let me choose supersede (link) / split the topic / discard.
|
|
69
|
+
`;
|
|
70
|
+
const HEAL_CMD = `---
|
|
71
|
+
description: Reconcile docs/code with Hunch's decision graph (doc≠graph drift), never rewriting prose silently
|
|
72
|
+
---
|
|
73
|
+
Reconcile decision-grounding drift for **$ARGUMENTS** (or the whole repo).
|
|
74
|
+
|
|
75
|
+
1. Run \`hunch drift\` (or \`hunch heal\`) to list doc≠graph **anchor-stale** sections — a file still anchored to a superseded decision while a current one exists. Only explicit topic anchors fire; never a semantic guess.
|
|
76
|
+
2. For each, assume the DOC is stale first (Heal A). Propose an edit bringing the file to the CURRENT decision; show it as a diff and wait for my confirm. Never rewrite prose silently.
|
|
77
|
+
3. Only if I explicitly say "the DECISION is stale, not the doc" (Heal B): run /capture to record a superseding decision, then return to step 2 — the prose re-derives from the new decision as a separate confirm.
|
|
78
|
+
4. Report: healed (Heal A), superseded (Heal B), skipped. Never touch the graph except via an explicit Heal B capture.
|
|
79
|
+
`;
|
|
58
80
|
/** A settings.json hook entry is Hunch's if any of its commands ends with the
|
|
59
81
|
* Hunch CLI entry + the `hook` subcommand (e.g. `…/index.js hook`). Matching the
|
|
60
82
|
* command TAIL — not the absolute path — makes re-init idempotent AND survives a
|
|
@@ -119,6 +141,8 @@ export function writeSlashCommands(root) {
|
|
|
119
141
|
["hunch-why.md", WHY_CMD],
|
|
120
142
|
["hunch-fix.md", FIX_CMD],
|
|
121
143
|
["hunch-fragile.md", FRAGILE_CMD],
|
|
144
|
+
["capture.md", CAPTURE_CMD],
|
|
145
|
+
["heal.md", HEAL_CMD],
|
|
122
146
|
];
|
|
123
147
|
for (const [name, body] of files) {
|
|
124
148
|
const p = join(dir, name);
|
|
@@ -5,4 +5,22 @@ export function flushPrivate(store, message) {
|
|
|
5
5
|
if (store.privateAutoCommit && store.privateDir)
|
|
6
6
|
commitAndPushHunch(store.privateDir, message);
|
|
7
7
|
}
|
|
8
|
+
/** Auto-commit the store a capture landed in. Returns what happened so callers can report it:
|
|
9
|
+
* "pushed" (private overlay, committed + pushed), "committed" (public .hunch/, commit only —
|
|
10
|
+
* rides the next push), or null (auto-commit off / no overlay for a private record). */
|
|
11
|
+
export function flushCapture(store, publicHunchDir, isPrivate, message) {
|
|
12
|
+
// Follow the same routing as HunchStore.captureHome: unified ("shared") mode homes
|
|
13
|
+
// EVERY capture in the overlay, so the flush must go there too — one source of truth.
|
|
14
|
+
if (store.captureHome(isPrivate) === "private") {
|
|
15
|
+
if (store.privateAutoCommit && store.privateDir) {
|
|
16
|
+
commitAndPushHunch(store.privateDir, message);
|
|
17
|
+
return "pushed";
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (!store.autoCommit)
|
|
22
|
+
return null;
|
|
23
|
+
commitAndPushHunch(publicHunchDir, message, { push: false });
|
|
24
|
+
return "committed";
|
|
25
|
+
}
|
|
8
26
|
//# sourceMappingURL=sync.js.map
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team discovery for the shared memory store — the COMMITTED half of the resolution chain.
|
|
3
|
+
* `.hunch/local.json` (gitignored, per-machine) says where THIS machine's overlay lives;
|
|
4
|
+
* `.hunch/team.json` (committed, public) says where the TEAM's shared store lives, so a
|
|
5
|
+
* fresh clone / a new teammate / a headless agent can auto-wire without being told.
|
|
6
|
+
* Written ONLY by `hunch shared --repo <url>` — `hunch private` never publishes its URL.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
12
|
+
import { hunchPaths, hunchPathsForDir } from "../core/paths.js";
|
|
13
|
+
import { mainWorktreeRoot } from "../extractors/git.js";
|
|
14
|
+
import { HunchStore } from "../store/hunchStore.js";
|
|
15
|
+
import { JsonStore } from "../store/jsonStore.js";
|
|
16
|
+
import { ensureSharedOverlayPointer } from "./worktree.js";
|
|
17
|
+
/** SECURITY GATE for team.json's URL. team.json is COMMITTED — in a freshly cloned
|
|
18
|
+
* (possibly untrusted) repo it is attacker-controlled, and ensureTeamOverlay auto-clones
|
|
19
|
+
* it on MCP server start. Without this gate a value like `--upload-pack=…` (argument
|
|
20
|
+
* smuggling) or `ext::sh -c …` (git's ext transport) is remote code execution from
|
|
21
|
+
* merely opening a repo. Allow only https:// / ssh:// / git:// / scp-style git@host:path,
|
|
22
|
+
* and never anything that could parse as a git flag. */
|
|
23
|
+
export function safeGitUrl(url) {
|
|
24
|
+
const u = url.trim();
|
|
25
|
+
if (!u || u.startsWith("-"))
|
|
26
|
+
return null; // flag smuggling
|
|
27
|
+
if (/^(https|ssh|git):\/\/[^\s]+$/i.test(u))
|
|
28
|
+
return u;
|
|
29
|
+
if (/^[A-Za-z0-9_.-]+@[A-Za-z0-9_.:-]+:[^\s]+$/.test(u) && !u.includes("::"))
|
|
30
|
+
return u; // scp-like, excludes ext::
|
|
31
|
+
// A plain absolute path (POSIX / Windows drive / UNC) — a network-mount team store or a
|
|
32
|
+
// local test remote. Safe: a local clone never executes hooks or remote helpers. The
|
|
33
|
+
// file:// URL FORM stays rejected (no legitimate team.json uses it; keeps the gate tight).
|
|
34
|
+
if (u.startsWith("/") || /^[A-Za-z]:[\\/]/.test(u) || u.startsWith("\\\\"))
|
|
35
|
+
return u;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** The committed team pointer, or null. Tolerant — an invalid file reads as absent, and
|
|
39
|
+
* a URL that fails the safety gate reads as absent too (never propagated to a consumer). */
|
|
40
|
+
export function readTeamConfig(root) {
|
|
41
|
+
try {
|
|
42
|
+
const file = join(hunchPaths(root).hunch, "team.json");
|
|
43
|
+
if (!existsSync(file))
|
|
44
|
+
return null;
|
|
45
|
+
const v = JSON.parse(readFileSync(file, "utf8"));
|
|
46
|
+
const url = typeof v.shared_repo === "string" ? safeGitUrl(v.shared_repo) : null;
|
|
47
|
+
return url ? { shared_repo: url } : null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Publish the team's shared-store URL (atomic; committed with the repo). */
|
|
54
|
+
export function writeTeamConfig(root, cfg) {
|
|
55
|
+
writeFileAtomic(join(hunchPaths(root).hunch, "team.json"), JSON.stringify(cfg, null, 2) + "\n");
|
|
56
|
+
}
|
|
57
|
+
/** Auto-wire this checkout to the team's shared store advertised in `.hunch/team.json`:
|
|
58
|
+
* clone it to the worktree-stable anchor, and register the gitignored local pointer +
|
|
59
|
+
* the git-common-dir pointer (mode "shared", auto-commit on) so every consumer — CLI,
|
|
60
|
+
* MCP server, hooks, all worktrees — resolves the same single source of truth.
|
|
61
|
+
* No-op (null) when an overlay is already configured, there's no team.json, or the
|
|
62
|
+
* clone fails (best-effort: never throws, never blocks startup). Returns the overlay
|
|
63
|
+
* hunch dir when wired. */
|
|
64
|
+
export function ensureTeamOverlay(root) {
|
|
65
|
+
try {
|
|
66
|
+
if (process.env.HUNCH_PRIVATE_DIR?.trim())
|
|
67
|
+
return null; // explicit env wins
|
|
68
|
+
const team = readTeamConfig(root);
|
|
69
|
+
if (!team)
|
|
70
|
+
return null;
|
|
71
|
+
const probe = new HunchStore(hunchPaths(root));
|
|
72
|
+
const configured = probe.privateDir;
|
|
73
|
+
probe.close();
|
|
74
|
+
if (configured && existsSync(configured))
|
|
75
|
+
return null; // already wired and alive
|
|
76
|
+
const anchor = mainWorktreeRoot(root);
|
|
77
|
+
const dest = join(anchor, ".hunch-private");
|
|
78
|
+
if (!existsSync(dest)) {
|
|
79
|
+
// Defense in depth on top of safeGitUrl: `--` stops flag parsing, the protocol
|
|
80
|
+
// allowlist + ext:: kill-switch block command-running transports, and no terminal
|
|
81
|
+
// prompt means a private remote can't hang a headless MCP/agent start.
|
|
82
|
+
const r = spawnSync("git", ["-c", "protocol.ext.allow=never", "clone", "--", team.shared_repo, dest], {
|
|
83
|
+
stdio: "ignore",
|
|
84
|
+
env: { ...process.env, GIT_ALLOW_PROTOCOL: "https:ssh:git:file", GIT_TERMINAL_PROMPT: "0" },
|
|
85
|
+
});
|
|
86
|
+
if (r.status !== 0)
|
|
87
|
+
return null; // offline / no access — stay unwired, never crash
|
|
88
|
+
}
|
|
89
|
+
const hunchDir = join(dest, ".hunch");
|
|
90
|
+
new JsonStore(hunchPathsForDir(hunchDir)).ensureDirs();
|
|
91
|
+
// Merge into any existing local.json (con_8460b6770f): a per-machine autoCommit
|
|
92
|
+
// opt-out must survive the auto-wiring; an unparseable file is left alone.
|
|
93
|
+
const localFile = join(hunchPaths(root).hunch, "local.json");
|
|
94
|
+
let existing = {};
|
|
95
|
+
if (existsSync(localFile)) {
|
|
96
|
+
try {
|
|
97
|
+
existing = JSON.parse(readFileSync(localFile, "utf8"));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
} // refuse to clobber an unparseable config
|
|
102
|
+
}
|
|
103
|
+
const autoCommit = existing.autoCommit !== false;
|
|
104
|
+
writeFileAtomic(localFile, JSON.stringify({ ...existing, privateDir: hunchDir, autoCommit, mode: "shared" }, null, 2) + "\n");
|
|
105
|
+
ensureSharedOverlayPointer(root, hunchDir, autoCommit, "shared");
|
|
106
|
+
return hunchDir;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=team.js.map
|
|
@@ -11,14 +11,16 @@ import { writeFileAtomic } from "../core/io.js";
|
|
|
11
11
|
/** Register the resolved private overlay at the shared git common dir, so every worktree
|
|
12
12
|
* of this repo auto-discovers the same memory. Idempotent (writes only when missing or
|
|
13
13
|
* changed). Stored ABSOLUTE — a worktree resolves relative paths from its OWN root.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
14
|
+
* Carries the overlay MODE so every worktree routes captures identically (shared =
|
|
15
|
+
* unified store, private = split). Returns true once the shared pointer is in place
|
|
16
|
+
* (memory is worktree-shared), false when there's no overlay configured or no git
|
|
17
|
+
* common dir. Reused by `init`/`worktree`/`private`/`shared`. */
|
|
18
|
+
export function ensureSharedOverlayPointer(root, overlayDir, autoCommit, mode = "private") {
|
|
17
19
|
const common = overlayDir ? gitCommonDir(root) : "";
|
|
18
20
|
if (!common || !overlayDir)
|
|
19
21
|
return false;
|
|
20
22
|
const file = join(common, "hunch", "local.json");
|
|
21
|
-
const want = JSON.stringify({ privateDir: resolve(overlayDir), autoCommit }, null, 2) + "\n";
|
|
23
|
+
const want = JSON.stringify({ privateDir: resolve(overlayDir), autoCommit, mode }, null, 2) + "\n";
|
|
22
24
|
try {
|
|
23
25
|
if (!(existsSync(file) && readFileSync(file, "utf8") === want)) {
|
|
24
26
|
mkdirSync(join(common, "hunch"), { recursive: true });
|
package/dist/mcp/server.js
CHANGED
|
@@ -16,12 +16,17 @@ import { decisionId } from "../core/ids.js";
|
|
|
16
16
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
17
17
|
import { knownRepoDeps } from "../synthesis/tripwires.js";
|
|
18
18
|
import { refreshExistingGrounding } from "../integrations/providers.js";
|
|
19
|
-
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff,
|
|
19
|
+
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, pullHunch } from "../extractors/git.js";
|
|
20
|
+
import { flushCapture } from "../integrations/sync.js";
|
|
21
|
+
import { ensureTeamOverlay } from "../integrations/team.js";
|
|
20
22
|
import { formatContext } from "../core/format.js";
|
|
21
23
|
import { compareCandidates } from "../core/compare.js";
|
|
22
24
|
import { checkConformance } from "../core/conformance.js";
|
|
23
25
|
import { renderMarkdown, verdict } from "../core/checkreport.js";
|
|
24
26
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
27
|
+
import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
|
|
28
|
+
import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
|
|
29
|
+
import { randomUUID } from "node:crypto";
|
|
25
30
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
26
31
|
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
27
32
|
// Read-side token budgets: every tool result is injected into a Claude Code
|
|
@@ -34,6 +39,25 @@ const QUERY_HITS = 8; // hunch_query matches (was 12)
|
|
|
34
39
|
const SEV_CONSTRAINT = { blocking: 3, warning: 2, advisory: 1 };
|
|
35
40
|
const SEV_BUG = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
36
41
|
const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` — ${hint}` : ""})` : "";
|
|
42
|
+
// Capture-session tokens live in src/core/capturetoken.ts (pure + testable). These
|
|
43
|
+
// thin wrappers bind the process clock and id source at the call site (§5 Stage 1).
|
|
44
|
+
const issueCaptureToken = () => issueToken(randomUUID, Date.now());
|
|
45
|
+
const consumeCaptureToken = (token) => consumeToken(token, Date.now());
|
|
46
|
+
/** The interrogation protocol returned by hunch_capture_decision. */
|
|
47
|
+
function grillingProtocol(topic, token) {
|
|
48
|
+
return [
|
|
49
|
+
"You are capturing an engineering decision into Hunch's graph. Run the GRILLING LOOP, then commit.",
|
|
50
|
+
"",
|
|
51
|
+
"RULES:",
|
|
52
|
+
"1. Grill ONE focused question at a time. Push back on hand-wavy answers. Resolve every branch of the decision tree before committing — an unexamined decision poisons the graph.",
|
|
53
|
+
`2. Confirm the TOPIC anchor with the human before committing${topic ? ` (proposed: "${topic}")` : ""}. Exactly one topic per decision; if it spans two, split into two captures.`,
|
|
54
|
+
"3. Capture REJECTED alternatives explicitly — for each, what it was and why not. This is what makes the decision enforceable (Veto/drift check against it).",
|
|
55
|
+
`4. Commit with hunch_record_decision, passing capture_token:"${token}" and the confirmed topic. The artifact is the graph write, not prose.`,
|
|
56
|
+
"5. On CONFLICT with an existing live decision for the topic, do NOT auto-supersede — Hunch refuses and presents both; let the human choose to supersede (link), split the topic, or discard.",
|
|
57
|
+
"",
|
|
58
|
+
"Required before commit: topic, title, decision, context (the rationale/why), alternatives_rejected. Missing any → keep grilling.",
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
37
61
|
/** Resolve a free-form target (symbol id / name / file path) to symbol records. */
|
|
38
62
|
function resolveSymbols(store, target) {
|
|
39
63
|
target = toPosixTarget(target);
|
|
@@ -53,6 +77,14 @@ function resolveFiles(store, target) {
|
|
|
53
77
|
return files.size ? [...files] : [toPosixTarget(target)];
|
|
54
78
|
}
|
|
55
79
|
export function buildServer(root) {
|
|
80
|
+
// Team auto-discovery: a committed .hunch/team.json advertises the shared store — a
|
|
81
|
+
// fresh clone (a new teammate, a headless agent, a CI workflow) wires itself BEFORE the
|
|
82
|
+
// store is constructed, so every consumer resolves the same single source of truth.
|
|
83
|
+
// Best-effort: offline / no team.json → proceed exactly as before.
|
|
84
|
+
try {
|
|
85
|
+
ensureTeamOverlay(root);
|
|
86
|
+
}
|
|
87
|
+
catch { /* never block server start */ }
|
|
56
88
|
const store = new HunchStore(hunchPaths(root));
|
|
57
89
|
// Two-way sync (read side): pull the private overlay's remote on startup, so THIS machine's
|
|
58
90
|
// session sees memory captured on other machines/worktrees before we index — making the
|
|
@@ -252,6 +284,39 @@ export function buildServer(root) {
|
|
|
252
284
|
});
|
|
253
285
|
return ok(`Decision timeline for "${target}" (newest first):\n${lines.join("\n")}`);
|
|
254
286
|
});
|
|
287
|
+
// -- hunch_capture_decision (decision-grounding: the grilling front door) --
|
|
288
|
+
server.registerTool("hunch_capture_decision", {
|
|
289
|
+
title: "Capture a decision (grilling interview)",
|
|
290
|
+
description: "Start a decision-capture interview: returns the grilling protocol (interrogate ONE question at a time until the decision tree is resolved) plus a capture-session token. Grill the human, then commit via hunch_record_decision with the token + confirmed topic. Use for '/capture', 'record this decision', 'grill me on this'. The token proves the write is the tail of an interview, not a silent guess.",
|
|
291
|
+
inputSchema: {
|
|
292
|
+
topic: z.string().optional().describe("proposed topic anchor (confirm with the human before committing)"),
|
|
293
|
+
seed: z.string().optional().describe("what the decision is about, to focus the first question"),
|
|
294
|
+
},
|
|
295
|
+
}, async ({ topic, seed }) => {
|
|
296
|
+
const token = issueCaptureToken();
|
|
297
|
+
return ok(`${grillingProtocol(topic, token)}${seed ? `\n\nSeed: ${seed}` : ""}`);
|
|
298
|
+
});
|
|
299
|
+
// -- hunch_current_decision (decision-grounding: current(topic)) ----------
|
|
300
|
+
server.registerTool("hunch_current_decision", {
|
|
301
|
+
title: "Current decision for a topic",
|
|
302
|
+
description: "Decision-grounding: return the single CURRENT (accepted, non-superseded) decision anchored to a topic — the authoritative answer a doc or diff is checked against, plus what it rejected. If a topic has NO current decision, or an unresolved collision (>1 live), it says so and injects nothing (fail-safe).",
|
|
303
|
+
inputSchema: { topic: z.string().describe("the decision anchor, e.g. 'auth-transport'") },
|
|
304
|
+
}, async ({ topic }) => {
|
|
305
|
+
const decs = store.recs("decisions");
|
|
306
|
+
const live = liveForTopic(decs, topic);
|
|
307
|
+
if (live.length === 0)
|
|
308
|
+
return ok(`No current decision for topic "${topic}". (Un-anchored, or never captured.)`);
|
|
309
|
+
if (live.length > 1) {
|
|
310
|
+
const list = live.map((d) => `${d.id} ("${d.title}")`).join(", ");
|
|
311
|
+
return ok(`Topic "${topic}" has an UNRESOLVED collision (${live.length} live decisions): ${list}.\nGrounding injects nothing until this is resolved — supersede one, or split the topic.`);
|
|
312
|
+
}
|
|
313
|
+
const d = live[0];
|
|
314
|
+
const rejected = rejectedForTopic(decs, topic);
|
|
315
|
+
const rej = rejected.length ? `\n rejected: ${rejected.join("; ")}` : "";
|
|
316
|
+
const hist = historyForTopic(decs, topic);
|
|
317
|
+
const chain = hist.length > 1 ? `\n history: ${hist.length} decisions on this topic (current is newest)` : "";
|
|
318
|
+
return ok(`Current decision for "${topic}": ${d.id} — "${d.title}" (${d.status}).\n ${d.decision}${rej}${chain}${provLine(d)}`);
|
|
319
|
+
});
|
|
255
320
|
// -- hunch_record_decision (write-back) -----------------------------------
|
|
256
321
|
server.registerTool("hunch_record_decision", {
|
|
257
322
|
title: "Record a decision (write-back)",
|
|
@@ -265,13 +330,15 @@ export function buildServer(root) {
|
|
|
265
330
|
alternatives_rejected: z.array(z.string()).optional(),
|
|
266
331
|
related_files: z.array(z.string()).optional(),
|
|
267
332
|
related_components: z.array(z.string()).optional(),
|
|
333
|
+
topic: z.string().optional().describe("decision-grounding anchor — one topic per decision; enables doc≠graph drift detection for it. Omit to leave un-anchored."),
|
|
268
334
|
status: z.enum(["proposed", "accepted", "rejected", "superseded"]).optional(),
|
|
269
335
|
commit: z.string().optional(),
|
|
270
336
|
supersedes: z.string().optional().describe("id of a decision this one replaces — closes its valid-time window (invalidate, don't delete)"),
|
|
271
337
|
private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — for sensitive decisions kept out of a public repo. Errors if no private store is configured."),
|
|
272
338
|
}),
|
|
339
|
+
capture_token: z.string().optional().describe("token from hunch_capture_decision — proves this write is the tail of a grilling interview. Omit only for a quick manual record (a deprecation nudge is returned)."),
|
|
273
340
|
},
|
|
274
|
-
}, async ({ decision }) => {
|
|
341
|
+
}, async ({ decision, capture_token }) => {
|
|
275
342
|
try {
|
|
276
343
|
// Commit-keyed on the CANONICAL full sha (resolved via git rev-parse), so a
|
|
277
344
|
// human passing the short sha they see in `commit` produces the SAME id as
|
|
@@ -294,6 +361,7 @@ export function buildServer(root) {
|
|
|
294
361
|
const rec = {
|
|
295
362
|
id,
|
|
296
363
|
title: decision.title,
|
|
364
|
+
topic: decision.topic ?? existing?.topic ?? null,
|
|
297
365
|
status: decision.status ?? "accepted",
|
|
298
366
|
context: decision.context ?? existing?.context ?? "",
|
|
299
367
|
decision: decision.decision ?? existing?.decision ?? "",
|
|
@@ -312,10 +380,32 @@ export function buildServer(root) {
|
|
|
312
380
|
provenance: { source, confidence: 0.95, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
|
|
313
381
|
date: now,
|
|
314
382
|
};
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
383
|
+
// Decision-grounding uniqueness guard (§4 Enforcement): never create a SECOND
|
|
384
|
+
// live decision for one topic. Exclude ONLY the incumbent this write will
|
|
385
|
+
// actually close — one resolvable in the SAME store the write lands in. A
|
|
386
|
+
// cross-store supersede (public write vs a private incumbent, or vice-versa)
|
|
387
|
+
// would no-op and leave two live decisions, so it is treated as unresolved
|
|
388
|
+
// (willClose=null) → the guard fires and refuses. Same-id re-record is allowed.
|
|
389
|
+
if (rec.topic && rec.status === "accepted") {
|
|
390
|
+
const willClose = decision.supersedes && store.decisionInStore(decision.supersedes, !!decision.private)
|
|
391
|
+
? decision.supersedes
|
|
392
|
+
: null;
|
|
393
|
+
const others = captureConflicts(store.recs("decisions"), rec.topic, id, willClose);
|
|
394
|
+
if (others.length) {
|
|
395
|
+
const list = others.map((d) => `${d.id} ("${d.title}")`).join(", ");
|
|
396
|
+
const crossStore = decision.supersedes && !willClose
|
|
397
|
+
? ` (note: supersedes:"${decision.supersedes}" is not in the ${decision.private ? "private" : "public"} store, so it can't be closed from here)`
|
|
398
|
+
: "";
|
|
399
|
+
return err(`Topic "${rec.topic}" already has a live decision: ${list}.${crossStore} ` +
|
|
400
|
+
`Hunch will not create a second current decision for one topic. Resolve it: ` +
|
|
401
|
+
`re-record with supersedes:<id> to replace it (linked, same store), pick a distinct topic to split, or discard this capture.`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Route the write to its ONE home (captureHome): an explicit private:true goes to
|
|
405
|
+
// the overlay (putPrivate throws rather than silently falling public); in unified
|
|
406
|
+
// ("shared") mode EVERY capture goes to the overlay; else the public store.
|
|
407
|
+
const home = store.captureHome(!!decision.private);
|
|
408
|
+
if (home === "private")
|
|
319
409
|
store.putPrivate("decisions", rec);
|
|
320
410
|
else
|
|
321
411
|
store.json.put("decisions", rec);
|
|
@@ -325,20 +415,31 @@ export function buildServer(root) {
|
|
|
325
415
|
// private overlay; a public one in the committed store. A private write never
|
|
326
416
|
// mutates the public store.
|
|
327
417
|
const superseded = decision.supersedes
|
|
328
|
-
? (
|
|
418
|
+
? (home === "private" ? store.supersedePrivate(decision.supersedes, rec) : store.supersede(decision.supersedes, rec))
|
|
329
419
|
: null;
|
|
330
420
|
store.reindex();
|
|
331
|
-
// Auto-flush the
|
|
332
|
-
// record
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
421
|
+
// Auto-flush the store the record landed in (on by default in every mode): a private
|
|
422
|
+
// record commits+pushes its overlay repo; a public one commits .hunch/ in THIS repo
|
|
423
|
+
// (commit only — it rides the user's next push, never auto-pushing their code branch).
|
|
424
|
+
const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`);
|
|
425
|
+
const flushed = flush === "pushed" ? ` (committed + pushed to the ${store.mode === "shared" ? "shared team store" : "private repo"})`
|
|
426
|
+
: flush === "committed" ? " (auto-committed to .hunch/ — rides your next push)" : "";
|
|
427
|
+
// Capture-session gate (staged deprecation, §9.3): a token proves an interview
|
|
428
|
+
// preceded the write. No token still writes (non-breaking), but returns a nudge
|
|
429
|
+
// toward /capture so the un-interviewed bypass is visible, not silent. A token
|
|
430
|
+
// presented but unknown to THIS process (server restart/expiry) is not shamed.
|
|
431
|
+
const gated = consumeCaptureToken(capture_token);
|
|
432
|
+
const captureNote = gated
|
|
433
|
+
? " [via capture front door]"
|
|
434
|
+
: capture_token
|
|
435
|
+
? ""
|
|
436
|
+
: "\n\n⚠ Recorded WITHOUT a capture interview. Prefer /capture (hunch_capture_decision), which grills the decision to a resolved state before writing — the graph should hold a well-examined decision, not a guess. (A future major version will require a capture token here.)";
|
|
338
437
|
const supNote = superseded ? ` Superseded ${superseded.id} (window closed at ${rec.valid_from}).` : "";
|
|
339
438
|
const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved — recorded as a standalone decision, not linked to a commit)` : "";
|
|
340
|
-
const where = decision.private
|
|
341
|
-
|
|
439
|
+
const where = decision.private
|
|
440
|
+
? ` [PRIVATE overlay — not committed to this repo]${flushed}`
|
|
441
|
+
: home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
|
|
442
|
+
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}${captureNote}`);
|
|
342
443
|
}
|
|
343
444
|
catch (e) {
|
|
344
445
|
return err(`Failed to record decision: ${e.message}`);
|
|
@@ -365,8 +466,9 @@ export function buildServer(root) {
|
|
|
365
466
|
const rec = buildCorrectionConstraint({ ...input, knownDeps: knownRepoDeps(root) }, new Date().toISOString());
|
|
366
467
|
// Private corrections go to the overlay (enforced locally via the merged read,
|
|
367
468
|
// never rendered into the public CI comment, which is public-only by construction).
|
|
368
|
-
const
|
|
369
|
-
|
|
469
|
+
const home = store.captureHome(!!input.private);
|
|
470
|
+
const existing = home === "private" ? undefined : store.json.get("constraints", rec.id);
|
|
471
|
+
if (home === "private")
|
|
370
472
|
store.putPrivate("constraints", rec);
|
|
371
473
|
else
|
|
372
474
|
store.json.put("constraints", rec);
|
|
@@ -375,17 +477,17 @@ export function buildServer(root) {
|
|
|
375
477
|
// Windsurf/AGENTS.md/CLAUDE.md), so a correction captured in one assistant is held
|
|
376
478
|
// by all of them. Public only — a private rule must never render into committed
|
|
377
479
|
// grounding. Refresh-only: it never scaffolds a doc the project opted out of.
|
|
378
|
-
if (
|
|
379
|
-
refreshExistingGrounding(root, store);
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
flushed = " (committed + pushed to the private repo)";
|
|
384
|
-
}
|
|
480
|
+
if (home === "public")
|
|
481
|
+
refreshExistingGrounding(root, store); // overlay rules never render into committed grounding
|
|
482
|
+
const flush = flushCapture(store, hunchPaths(root).hunch, !!input.private, `hunch: capture ${rec.id}`);
|
|
483
|
+
const flushed = flush === "pushed" ? ` (committed + pushed to the ${store.mode === "shared" ? "shared team store" : "private repo"})`
|
|
484
|
+
: flush === "committed" ? " (auto-committed to .hunch/ — rides your next push)" : "";
|
|
385
485
|
const enforce = rec.severity === "blocking"
|
|
386
486
|
? "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"
|
|
387
487
|
: "flags violating edits and PRs (advisory)";
|
|
388
|
-
const where = input.private
|
|
488
|
+
const where = input.private
|
|
489
|
+
? ` [PRIVATE overlay — not committed to this repo]${flushed}`
|
|
490
|
+
: home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
|
|
389
491
|
return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where} It now ${enforce}.`);
|
|
390
492
|
}
|
|
391
493
|
catch (e) {
|