@davesheffer/hunch 1.4.2 → 1.6.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 +117 -23
- package/dist/cli/index.js +269 -57
- package/dist/core/agenthook.js +197 -0
- package/dist/core/autoreview.js +52 -0
- package/dist/core/config.js +1 -1
- package/dist/core/drift.js +25 -3
- package/dist/core/hookcache.js +1 -1
- package/dist/core/refrepair.js +33 -0
- package/dist/extractors/git.js +31 -1
- package/dist/integrations/hooks.js +4 -0
- package/dist/integrations/providers.js +177 -23
- package/dist/mcp/server.js +23 -22
- package/dist/store/hunchStore.js +26 -5
- package/dist/synthesis/provider.js +58 -0
- package/dist/synthesis/synthesize.js +32 -17
- package/package.json +6 -2
package/dist/cli/index.js
CHANGED
|
@@ -29,7 +29,7 @@ import { indexRepo } from "../extractors/indexer.js";
|
|
|
29
29
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
30
30
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
31
31
|
import { selectProvider } from "../synthesis/provider.js";
|
|
32
|
-
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
|
|
32
|
+
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
|
|
33
33
|
import { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrations/team.js";
|
|
34
34
|
import { runbookId, decisionId } from "../core/ids.js";
|
|
35
35
|
import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
|
|
@@ -50,8 +50,10 @@ import { formatContext, formatStructure } from "../core/format.js";
|
|
|
50
50
|
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
|
|
51
51
|
import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
|
|
52
52
|
import { injectionMode } from "../core/hookcache.js";
|
|
53
|
+
import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
|
|
53
54
|
import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
|
|
54
55
|
import { draftDuplicateOf } from "../core/dupdetect.js";
|
|
56
|
+
import { planAutoReview, planMutations } from "../core/autoreview.js";
|
|
55
57
|
import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
|
|
56
58
|
import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
|
|
57
59
|
import { computeDrift } from "../core/drift.js";
|
|
@@ -68,6 +70,7 @@ import { mergeHunchJson } from "../store/merge.js";
|
|
|
68
70
|
import { movePublicMemoryToPrivate } from "../store/privateMigrate.js";
|
|
69
71
|
import { ENTITY_KINDS } from "../core/types.js";
|
|
70
72
|
import { planCompaction } from "../store/compact.js";
|
|
73
|
+
import { repairDecisionReference } from "../core/refrepair.js";
|
|
71
74
|
import { resolveInvocation } from "./invocation.js";
|
|
72
75
|
const program = new Command();
|
|
73
76
|
program.name("hunch").description("Hunch — an Engineering Memory OS: a git-native reasoning graph for your codebase.").version(HUNCH_VERSION);
|
|
@@ -81,12 +84,12 @@ function storeFor() {
|
|
|
81
84
|
// ---- init -----------------------------------------------------------------
|
|
82
85
|
program
|
|
83
86
|
.command("init")
|
|
84
|
-
.description("Scaffold .hunch/, index the repo, install the git hook, and wire up your coding assistants (Claude Code, Cursor, VS Code, Windsurf, Codex).")
|
|
87
|
+
.description("Scaffold .hunch/, index the repo, install the git hook, and wire up your coding assistants (Claude Code, Cursor, VS Code, Windsurf, Antigravity, Codex).")
|
|
85
88
|
.option("--no-index", "skip the initial repo index")
|
|
86
89
|
.option("--no-enforce", "do not install the advisory pre-commit constraint guard")
|
|
87
90
|
.option("--enforce-strict", "make the pre-commit guard FAIL the commit on a direct, high-confidence, non-stale blocking invariant")
|
|
88
|
-
.option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Codex / AGENTS.md)")
|
|
89
|
-
.option("--no-agent-hooks", "skip installing
|
|
91
|
+
.option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Windsurf / Antigravity / Codex / AGENTS.md)")
|
|
92
|
+
.option("--no-agent-hooks", "skip installing all assistant lifecycle hooks (MCP + grounding are still configured)")
|
|
90
93
|
.option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
|
|
91
94
|
.option("--private-sync", "post-commit synthesis writes captured decisions into the overlay repo (HUNCH_PRIVATE_DIR), never the public store")
|
|
92
95
|
.option("--shared-sync", "alias of --private-sync (for teams using one shared overlay repo for any code repo)")
|
|
@@ -138,7 +141,7 @@ program
|
|
|
138
141
|
}
|
|
139
142
|
if (isGitRepo(root)) {
|
|
140
143
|
const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
|
|
141
|
-
const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit });
|
|
144
|
+
const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit, localOnly: syncToOverlay });
|
|
142
145
|
console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
|
|
143
146
|
const m = installMergeDriver(root, inv.shell);
|
|
144
147
|
console.log(` ✓ team merge driver ${m.action}`);
|
|
@@ -172,16 +175,16 @@ program
|
|
|
172
175
|
// Firmness: stamp .hunch/config.json (default advisory) so `hunch hook` reads a
|
|
173
176
|
// level even before the user runs `hunch firmness` (--firmness validated above).
|
|
174
177
|
const firmness = writeConfig(paths, opts.firmness ? { firmness: opts.firmness } : {}).firmness;
|
|
175
|
-
//
|
|
176
|
-
//
|
|
178
|
+
// Claude's native hooks run alongside provider-specific hooks below. Every
|
|
179
|
+
// adapter reads firmness at run time, so changing it needs no config rewrite.
|
|
177
180
|
if (opts.agentHooks !== false) {
|
|
178
181
|
const a = installClaudeHooks(root, `${inv.shell} hook`);
|
|
179
182
|
console.log(` ✓ Claude Code agent hooks ${a.action} (firmness: ${firmness} — change with \`hunch firmness <level>\`)`);
|
|
180
183
|
}
|
|
181
|
-
// Multi-assistant compatibility:
|
|
182
|
-
//
|
|
184
|
+
// Multi-assistant compatibility: MCP + grounding + lifecycle adapters share
|
|
185
|
+
// the same .hunch/ graph across Cursor / VS Code / Windsurf / Antigravity.
|
|
183
186
|
if (opts.providers !== false) {
|
|
184
|
-
const ps = scaffoldProviders(root, inv.mcp, store);
|
|
187
|
+
const ps = scaffoldProviders(root, inv.mcp, store, { agentHooks: opts.agentHooks !== false });
|
|
185
188
|
const ok = ps.filter((p) => !p.error);
|
|
186
189
|
const total = ok.reduce((a, p) => a + p.files.length, 0);
|
|
187
190
|
console.log(` ✓ wrote ${total} multi-assistant config file(s) → ${ok.map((p) => p.assistant).join(", ")}`);
|
|
@@ -320,7 +323,16 @@ program
|
|
|
320
323
|
return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
|
|
321
324
|
}
|
|
322
325
|
store.json.ensureDirs();
|
|
323
|
-
const r = await syncCommit(store, root, sha ?? headSha(root), {
|
|
326
|
+
const r = await syncCommit(store, root, sha ?? headSha(root), {
|
|
327
|
+
force: opts.force,
|
|
328
|
+
private: toOverlay,
|
|
329
|
+
// A split-private overlay is sensitive/local by definition. A shared store
|
|
330
|
+
// is an explicit team policy and may keep its configured synthesis provider.
|
|
331
|
+
localOnly: toOverlay && store.mode === "private",
|
|
332
|
+
deep: opts.deep,
|
|
333
|
+
verify: opts.verify,
|
|
334
|
+
samples: parseSamples(opts.samples),
|
|
335
|
+
});
|
|
324
336
|
if (r.status === "written") {
|
|
325
337
|
store.reindex();
|
|
326
338
|
// Don't rewrite grounding from the hook — it would dirty the working tree on
|
|
@@ -455,7 +467,7 @@ function configureOverlay(dir, opts, mode) {
|
|
|
455
467
|
// 4) route post-commit synthesis to the overlay (local hook, never committed)
|
|
456
468
|
let hookNote = "";
|
|
457
469
|
if (opts.hook && isGitRepo(root)) {
|
|
458
|
-
const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit });
|
|
470
|
+
const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit, localOnly: mode === "private" });
|
|
459
471
|
hookNote = ` ✓ post-commit hook ${h.action} — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}\n`;
|
|
460
472
|
}
|
|
461
473
|
// 5) one-time migration: MOVE existing public memory INTO the overlay, then make
|
|
@@ -1047,16 +1059,24 @@ program
|
|
|
1047
1059
|
.description("Capture a Bug from a failing test (symptom + suspect ranking).")
|
|
1048
1060
|
.requiredOption("--test <id>", "failing test id/name")
|
|
1049
1061
|
.requiredOption("--message <msg>", "failure message / stack")
|
|
1062
|
+
.option("--private", "keep the bug and its failure text in the private overlay; uses deterministic local synthesis")
|
|
1050
1063
|
.action(async (opts) => {
|
|
1051
1064
|
const { store, root } = storeFor();
|
|
1065
|
+
if (opts.private && !store.hasPrivate) {
|
|
1066
|
+
store.close();
|
|
1067
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
1068
|
+
}
|
|
1052
1069
|
store.json.ensureDirs();
|
|
1053
|
-
const r = await recordFailure(store, root, { test: opts.test, message: opts.message });
|
|
1070
|
+
const r = await recordFailure(store, root, { test: opts.test, message: opts.message }, { private: opts.private });
|
|
1054
1071
|
store.reindex();
|
|
1055
|
-
|
|
1072
|
+
const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${r.bug.id}`);
|
|
1073
|
+
console.log(`✓ recorded bug ${r.bug.id} via ${r.provider}: "${r.bug.title}"${opts.private ? " [private overlay; local-only synthesis]" : ""}`);
|
|
1056
1074
|
if (r.bug.lineage.recurrence_of)
|
|
1057
1075
|
console.log(` ↳ recurrence of ${r.bug.lineage.recurrence_of}`);
|
|
1058
1076
|
if (r.constraint)
|
|
1059
1077
|
console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
|
|
1078
|
+
if (flush === "pushed")
|
|
1079
|
+
console.log(" ↳ private memory committed + pushed");
|
|
1060
1080
|
store.close();
|
|
1061
1081
|
});
|
|
1062
1082
|
// ---- record-constraint (human-authored invariant) -------------------------
|
|
@@ -1073,11 +1093,16 @@ program
|
|
|
1073
1093
|
.option("--forbid-dep <names>", "comma-sep imports that BREAK the rule (parsed-import precise; e.g. lodash) — blocks the real violation, immune to staleness")
|
|
1074
1094
|
.option("--forbid-symbol <names>", "comma-sep identifier names that break the rule")
|
|
1075
1095
|
.option("--match <regex>", "textual line regex (lint-grade last resort; prefer --forbid-dep/--forbid-symbol)")
|
|
1096
|
+
.option("--private", "write the invariant into the private overlay (local enforcement only; never included in public CI output)")
|
|
1076
1097
|
.action((statement, opts) => {
|
|
1077
1098
|
const SEV = ["advisory", "warning", "blocking"];
|
|
1078
1099
|
if (!SEV.includes(opts.severity))
|
|
1079
1100
|
return fail(`--severity must be one of: ${SEV.join(", ")}`);
|
|
1080
1101
|
const { store, root } = storeFor();
|
|
1102
|
+
if (opts.private && !store.hasPrivate) {
|
|
1103
|
+
store.close();
|
|
1104
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
1105
|
+
}
|
|
1081
1106
|
store.json.ensureDirs();
|
|
1082
1107
|
const scope = opts.scope.split(",").map((s) => toPosixTarget(s.trim())).filter(Boolean);
|
|
1083
1108
|
const csv = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : []);
|
|
@@ -1107,10 +1132,14 @@ program
|
|
|
1107
1132
|
valid_from: new Date().toISOString(),
|
|
1108
1133
|
valid_to: null,
|
|
1109
1134
|
provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: new Date().toISOString() },
|
|
1110
|
-
});
|
|
1135
|
+
}, opts.private);
|
|
1111
1136
|
store.reindex();
|
|
1112
|
-
|
|
1113
|
-
|
|
1137
|
+
// Public grounding is a publishable artifact. Private rules stay local and
|
|
1138
|
+
// are surfaced by local checks/MCP, never copied into committed agent docs.
|
|
1139
|
+
if (store.captureHome(!!opts.private) === "public")
|
|
1140
|
+
refreshExistingGrounding(root, store);
|
|
1141
|
+
const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${c.id}`);
|
|
1142
|
+
console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})${opts.private ? " [private overlay]" : ""}`);
|
|
1114
1143
|
if (derived && c.forbids?.deps.length)
|
|
1115
1144
|
console.log(` ↳ matcher: forbids import of ${c.forbids.deps.join(", ")} (precise, immune to staleness)`);
|
|
1116
1145
|
if (c.severity === "blocking" && !effectiveForbids(c)) {
|
|
@@ -1119,6 +1148,8 @@ program
|
|
|
1119
1148
|
console.log(` ⚠ scope-only — this will downgrade to advisory once a file in scope is changed after today.`);
|
|
1120
1149
|
console.log(` To block the actual violation across the file's life, add --forbid-dep <pkg> (or --forbid-symbol / --match).`);
|
|
1121
1150
|
}
|
|
1151
|
+
if (flush === "pushed")
|
|
1152
|
+
console.log(" ↳ private memory committed + pushed");
|
|
1122
1153
|
store.close();
|
|
1123
1154
|
});
|
|
1124
1155
|
// ---- test (failure-learning loop) -----------------------------------------
|
|
@@ -1127,8 +1158,13 @@ program
|
|
|
1127
1158
|
.description("Run the test suite; capture failures as Bugs (suspects + recurrence → Constraints), mark passing tests' bugs fixed.")
|
|
1128
1159
|
.argument("[cmd...]", "test command to run (default: `npm test`)")
|
|
1129
1160
|
.option("--dry-run", "show what would be captured without writing")
|
|
1161
|
+
.option("--private", "keep captured test failures in the private overlay and use deterministic local synthesis")
|
|
1130
1162
|
.action(async (cmd, opts) => {
|
|
1131
1163
|
const { store, root } = storeFor();
|
|
1164
|
+
if (opts.private && !store.hasPrivate) {
|
|
1165
|
+
store.close();
|
|
1166
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
1167
|
+
}
|
|
1132
1168
|
store.json.ensureDirs();
|
|
1133
1169
|
// Run as a shell string (not argv) so the npm/test-runner shim resolves on
|
|
1134
1170
|
// Windows and avoids Node's DEP0190 args+shell warning — same lesson as the
|
|
@@ -1155,7 +1191,7 @@ program
|
|
|
1155
1191
|
store.close();
|
|
1156
1192
|
return;
|
|
1157
1193
|
}
|
|
1158
|
-
const cap = await captureTestRun(store, root, { report, status: run.status, cmd: cmdStr, output });
|
|
1194
|
+
const cap = await captureTestRun(store, root, { report, status: run.status, cmd: cmdStr, output, private: opts.private });
|
|
1159
1195
|
for (const { bug, constraint } of cap.results) {
|
|
1160
1196
|
if (constraint)
|
|
1161
1197
|
console.log(` ⚠ ${bug.id} "${bug.title}" → promoted constraint ${constraint.id} [${constraint.severity}]`);
|
|
@@ -1165,6 +1201,8 @@ program
|
|
|
1165
1201
|
for (const b of cap.fixed)
|
|
1166
1202
|
console.log(` ✓ ${b.id} "${b.title}" → fixed (test passing)`);
|
|
1167
1203
|
store.reindex();
|
|
1204
|
+
if (cap.results.length || cap.fixed.length)
|
|
1205
|
+
flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture test results`);
|
|
1168
1206
|
store.close();
|
|
1169
1207
|
const recurrences = cap.results.filter((r) => r.bug.lineage.recurrence_of).length;
|
|
1170
1208
|
const promoted = cap.results.filter((r) => r.constraint).length;
|
|
@@ -1229,6 +1267,7 @@ program
|
|
|
1229
1267
|
.command("check")
|
|
1230
1268
|
.description("Flag changes that touch a do-not-break invariant — the local guardrail AND the CI/PR Constraint Guard. Also flags (advisory) symbols you add that already exist elsewhere — possible re-implementation/sprawl.")
|
|
1231
1269
|
.option("--staged", "check git staged files (default)")
|
|
1270
|
+
.option("--working", "check all working-tree edits vs HEAD (staged, unstaged, and untracked files)")
|
|
1232
1271
|
.option("--commit <sha>", "check a specific commit's files")
|
|
1233
1272
|
.option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main) — for CI")
|
|
1234
1273
|
.option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
|
|
@@ -1236,9 +1275,9 @@ program
|
|
|
1236
1275
|
.option("--blast", "also print the dependency blast radius of the changed files")
|
|
1237
1276
|
.option("--public-only", "exclude the private overlay (HUNCH_PRIVATE_DIR) from the report — use for any output that may be posted publicly (the CI PR comment passes this)")
|
|
1238
1277
|
.action((opts) => {
|
|
1239
|
-
const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged"].filter(Boolean);
|
|
1278
|
+
const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged", opts.working && "--working"].filter(Boolean);
|
|
1240
1279
|
if (sources.length > 1)
|
|
1241
|
-
return fail(`pick one of --staged / --commit / --base (got ${sources.join(", ")})`);
|
|
1280
|
+
return fail(`pick one of --staged / --working / --commit / --base (got ${sources.join(", ")})`);
|
|
1242
1281
|
const markdown = opts.format === "markdown";
|
|
1243
1282
|
const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
|
|
1244
1283
|
const { store, root } = storeFor();
|
|
@@ -1251,7 +1290,8 @@ program
|
|
|
1251
1290
|
store.reindex(); // blast radius walks the edge graph — make the index current
|
|
1252
1291
|
const files = opts.commit ? commitFiles(opts.commit, root)
|
|
1253
1292
|
: opts.base ? rangeFiles(opts.base, root)
|
|
1254
|
-
:
|
|
1293
|
+
: opts.working ? workingFiles(root)
|
|
1294
|
+
: stagedFiles(root);
|
|
1255
1295
|
if (!files.length) {
|
|
1256
1296
|
console.log(markdown ? renderMarkdown(emptyReport) : "No changed files to check.");
|
|
1257
1297
|
store.close();
|
|
@@ -1261,7 +1301,7 @@ program
|
|
|
1261
1301
|
// code) + REDUNDANT (adds a symbol already defined elsewhere — advisory) + the
|
|
1262
1302
|
// hardened strict gate + causal `why` citations — all assembled by the shared
|
|
1263
1303
|
// store.buildCheckReport (also used by the hunch_merge_verdict tool).
|
|
1264
|
-
const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : stagedDiff(root);
|
|
1304
|
+
const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
|
|
1265
1305
|
const report = store.buildCheckReport(files, diff, {
|
|
1266
1306
|
strict: !!opts.strict,
|
|
1267
1307
|
lastChange: (f) => lastChangeDate(f, root),
|
|
@@ -1488,7 +1528,7 @@ program
|
|
|
1488
1528
|
// ---- firmness (agent-hook enforcement level) ------------------------------
|
|
1489
1529
|
program
|
|
1490
1530
|
.command("firmness")
|
|
1491
|
-
.description("Get or set how firmly
|
|
1531
|
+
.description("Get or set how firmly agent lifecycle hooks enforce Hunch before edits.")
|
|
1492
1532
|
.argument("[level]", "off | advisory | firm | strict (omit to print the current level)")
|
|
1493
1533
|
.action((level) => {
|
|
1494
1534
|
const paths = hunchPaths(findRoot());
|
|
@@ -1501,7 +1541,7 @@ program
|
|
|
1501
1541
|
return fail(`firmness must be one of: ${FIRMNESS_LEVELS.join(", ")}`);
|
|
1502
1542
|
}
|
|
1503
1543
|
const next = writeConfig(paths, { firmness: level }).firmness;
|
|
1504
|
-
console.log(`✓ firmness set to ${next} (takes effect on the next edit — no
|
|
1544
|
+
console.log(`✓ firmness set to ${next} (takes effect on the next agent edit — no restart needed).`);
|
|
1505
1545
|
});
|
|
1506
1546
|
// ---- status (enforcement readiness at a glance) ---------------------------
|
|
1507
1547
|
program
|
|
@@ -1542,16 +1582,22 @@ program
|
|
|
1542
1582
|
console.log("");
|
|
1543
1583
|
store.close();
|
|
1544
1584
|
});
|
|
1545
|
-
// ---- hook (
|
|
1585
|
+
// ---- hook (multi-agent lifecycle hook handler) ----------------------------
|
|
1546
1586
|
program
|
|
1547
1587
|
.command("hook")
|
|
1548
|
-
.description("
|
|
1549
|
-
.
|
|
1588
|
+
.description("Agent-agnostic hook handler: normalizes Claude, VS Code, Cursor, Windsurf, and Antigravity events into Hunch context and strict policy checks. Reads hook JSON on stdin.")
|
|
1589
|
+
.option("--provider <provider>", "hook event dialect: claude | vscode | cursor | windsurf | antigravity", "claude")
|
|
1590
|
+
.action(async (opts) => {
|
|
1550
1591
|
// A hook MUST NEVER break the agent: on ANY error or unrecognized input we
|
|
1551
1592
|
// emit nothing and exit 0 (the action defers to Claude Code's normal flow).
|
|
1552
1593
|
let store = null;
|
|
1553
1594
|
try {
|
|
1554
|
-
const
|
|
1595
|
+
const provider = hookProvider(opts.provider);
|
|
1596
|
+
if (!provider)
|
|
1597
|
+
return;
|
|
1598
|
+
const evt = normalizeHookEvent(JSON.parse(await readStdin()), provider);
|
|
1599
|
+
if (!evt)
|
|
1600
|
+
return;
|
|
1555
1601
|
const root = findRoot();
|
|
1556
1602
|
const paths = hunchPaths(root);
|
|
1557
1603
|
const firmness = readConfig(paths).firmness;
|
|
@@ -1581,7 +1627,7 @@ program
|
|
|
1581
1627
|
const verdict = stopVerdict(st, firmness);
|
|
1582
1628
|
if (verdict.block) {
|
|
1583
1629
|
savePipelineState(evt.session_id, verdict.state);
|
|
1584
|
-
|
|
1630
|
+
emitStop(provider, verdict.reason);
|
|
1585
1631
|
}
|
|
1586
1632
|
return;
|
|
1587
1633
|
}
|
|
@@ -1603,7 +1649,7 @@ program
|
|
|
1603
1649
|
// different content, so it always comes through (dec_244397d920).
|
|
1604
1650
|
if (injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
|
|
1605
1651
|
return;
|
|
1606
|
-
emitContext("UserPromptSubmit", text);
|
|
1652
|
+
emitContext(provider, "UserPromptSubmit", text);
|
|
1607
1653
|
return;
|
|
1608
1654
|
}
|
|
1609
1655
|
if (evt.hook_event_name === "SessionStart") {
|
|
@@ -1619,7 +1665,7 @@ program
|
|
|
1619
1665
|
if (!decisions.length) {
|
|
1620
1666
|
// Fresh graph: nothing to orient on, but the operating loop still ships.
|
|
1621
1667
|
if (pipelineEnabled())
|
|
1622
|
-
emitContext("SessionStart", PIPELINE_LOOP);
|
|
1668
|
+
emitContext(provider, "SessionStart", PIPELINE_LOOP);
|
|
1623
1669
|
return;
|
|
1624
1670
|
}
|
|
1625
1671
|
const L = [];
|
|
@@ -1639,7 +1685,13 @@ program
|
|
|
1639
1685
|
// (the zod bench showed ambient skills are read in ~0% of sessions).
|
|
1640
1686
|
if (pipelineEnabled())
|
|
1641
1687
|
L.push("", PIPELINE_LOOP);
|
|
1642
|
-
|
|
1688
|
+
const orientation = L.join("\n");
|
|
1689
|
+
// Antigravity's nearest equivalent is PreInvocation, which can fire
|
|
1690
|
+
// repeatedly in one conversation. Deduplicate it just like edit
|
|
1691
|
+
// grounding so it remains an orientation, not a context flood.
|
|
1692
|
+
if (provider === "antigravity" && injectionMode(evt.session_id, "orientation", orientation) === "delta")
|
|
1693
|
+
return;
|
|
1694
|
+
emitContext(provider, "SessionStart", orientation);
|
|
1643
1695
|
}
|
|
1644
1696
|
finally {
|
|
1645
1697
|
s.close();
|
|
@@ -1671,7 +1723,7 @@ program
|
|
|
1671
1723
|
const proposedLines = proposedEditLines(evt.tool_input);
|
|
1672
1724
|
const deny = blockingInScope(store, target, proposedLines);
|
|
1673
1725
|
if (deny) {
|
|
1674
|
-
emitDeny(deny.reason);
|
|
1726
|
+
emitDeny(provider, deny.reason);
|
|
1675
1727
|
return;
|
|
1676
1728
|
}
|
|
1677
1729
|
// Veto Guard (live): the proposed edit text re-introduces an approach an
|
|
@@ -1679,7 +1731,7 @@ program
|
|
|
1679
1731
|
// only human-confirmed tripwires deny.
|
|
1680
1732
|
const vetoDeny = proposedLines.length ? vetoInScope(store, target, proposedLines) : null;
|
|
1681
1733
|
if (vetoDeny) {
|
|
1682
|
-
emitDeny(vetoDeny.reason);
|
|
1734
|
+
emitDeny(provider, vetoDeny.reason);
|
|
1683
1735
|
return;
|
|
1684
1736
|
}
|
|
1685
1737
|
}
|
|
@@ -1722,10 +1774,10 @@ program
|
|
|
1722
1774
|
// the full 10-16KB block. Any record change re-sends the full text; the
|
|
1723
1775
|
// strict-gate deny path above never routes through this (dec_244397d920).
|
|
1724
1776
|
if (injectionMode(evt.session_id, `pre:${target}`, text) === "delta") {
|
|
1725
|
-
emitContext("PreToolUse", `Hunch grounding for ${target}: unchanged this session (${ctx.decisions.length} decision(s), ${ctx.constraints.length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
|
|
1777
|
+
emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${ctx.decisions.length} decision(s), ${ctx.constraints.length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
|
|
1726
1778
|
return;
|
|
1727
1779
|
}
|
|
1728
|
-
emitContext("PreToolUse", text);
|
|
1780
|
+
emitContext(provider, "PreToolUse", text);
|
|
1729
1781
|
}
|
|
1730
1782
|
catch {
|
|
1731
1783
|
// swallow — never block an edit on a hook failure
|
|
@@ -1774,29 +1826,40 @@ program
|
|
|
1774
1826
|
.option("--accept-verified", "batch-accept every Critic-verified, well-grounded draft (>= --min-grounded)")
|
|
1775
1827
|
.option("--reject-duplicates", "batch-reject drafts that near-duplicate an accepted record (deterministic term+file similarity — hygiene, not judgment)")
|
|
1776
1828
|
.option("--min-grounded <n>", "grounded-ness threshold for the ready group / --accept-verified", String(READY_MIN_GROUNDED))
|
|
1829
|
+
.option("--private", "include local private/shared-overlay drafts; terminal output may contain private memory")
|
|
1777
1830
|
.action((opts) => {
|
|
1778
1831
|
const { store, root } = storeFor();
|
|
1779
1832
|
const minGrounded = Number.isFinite(Number(opts.minGrounded)) ? Number(opts.minGrounded) : READY_MIN_GROUNDED;
|
|
1833
|
+
if (opts.private && !store.hasPrivate) {
|
|
1834
|
+
store.close();
|
|
1835
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
1836
|
+
}
|
|
1837
|
+
const decisions = () => opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
|
|
1838
|
+
let publicGroundingChanged = false;
|
|
1780
1839
|
if (opts.accept) {
|
|
1781
|
-
const d = store.json.get("decisions", opts.accept);
|
|
1840
|
+
const d = opts.private ? store.getRec("decisions", opts.accept) : store.json.get("decisions", opts.accept);
|
|
1782
1841
|
if (!d) {
|
|
1783
1842
|
store.close();
|
|
1784
1843
|
return fail(`decision ${opts.accept} not found`);
|
|
1785
1844
|
}
|
|
1845
|
+
const inPrivate = !!store.getPrivateRec("decisions", d.id);
|
|
1786
1846
|
const { source, armed } = acceptDecision(store, d);
|
|
1787
1847
|
store.reindex();
|
|
1788
|
-
|
|
1848
|
+
if (!inPrivate) {
|
|
1849
|
+
refreshExistingGrounding(root, store);
|
|
1850
|
+
publicGroundingChanged = true;
|
|
1851
|
+
}
|
|
1789
1852
|
console.log(`✓ accepted ${opts.accept} (now ${source}, confidence 0.95${armed ? `, ${armed} tripwire(s) now blocking` : ""})`);
|
|
1790
1853
|
}
|
|
1791
1854
|
else if (opts.reject) {
|
|
1792
|
-
const ok2 = store.json.delete("decisions", opts.reject);
|
|
1855
|
+
const ok2 = opts.private ? store.deleteWhereItLives("decisions", opts.reject) : store.json.delete("decisions", opts.reject);
|
|
1793
1856
|
store.reindex();
|
|
1794
1857
|
console.log(ok2 ? `✓ rejected and removed ${opts.reject}` : `decision ${opts.reject} not found`);
|
|
1795
1858
|
}
|
|
1796
1859
|
else if (opts.rejectDuplicates) {
|
|
1797
1860
|
// Deterministic hygiene, not a trust decision (dec_a466655539 stays intact):
|
|
1798
1861
|
// only drafts, only against ACCEPTED records, conservative threshold.
|
|
1799
|
-
const all =
|
|
1862
|
+
const all = decisions();
|
|
1800
1863
|
const drafts = all.filter((d) => d.status === "proposed" && !d.provenance.source.includes("human_confirmed"));
|
|
1801
1864
|
const dupes = drafts
|
|
1802
1865
|
.map((d) => ({ d, m: draftDuplicateOf(d, all) }))
|
|
@@ -1807,7 +1870,7 @@ program
|
|
|
1807
1870
|
else {
|
|
1808
1871
|
let removed = 0;
|
|
1809
1872
|
for (const { d, m } of dupes) {
|
|
1810
|
-
if (store.json.delete("decisions", d.id))
|
|
1873
|
+
if ((opts.private ? store.deleteWhereItLives("decisions", d.id) : store.json.delete("decisions", d.id)))
|
|
1811
1874
|
removed++;
|
|
1812
1875
|
console.log(` ✗ ${d.id} — "${d.title}"\n duplicate of ${m.of.id} — "${m.of.title}" (${Math.round(m.score * 100)}%)`);
|
|
1813
1876
|
}
|
|
@@ -1818,24 +1881,28 @@ program
|
|
|
1818
1881
|
else if (opts.acceptVerified) {
|
|
1819
1882
|
// Batch path: only Critic-verified, well-grounded drafts qualify — still the
|
|
1820
1883
|
// human-driven accept gate (the operator runs this), just over a safe subset.
|
|
1821
|
-
const proposed =
|
|
1884
|
+
const proposed = decisions().filter((d) => d.status === "proposed");
|
|
1822
1885
|
const { ready } = partitionReview(proposed, minGrounded);
|
|
1823
1886
|
if (!ready.length) {
|
|
1824
1887
|
console.log(`✓ No Critic-verified drafts at grounded ≥ ${minGrounded} to batch-accept.`);
|
|
1825
1888
|
}
|
|
1826
1889
|
else {
|
|
1827
1890
|
let armedTotal = 0;
|
|
1828
|
-
for (const it of ready)
|
|
1891
|
+
for (const it of ready) {
|
|
1892
|
+
if (!store.getPrivateRec("decisions", it.d.id))
|
|
1893
|
+
publicGroundingChanged = true;
|
|
1829
1894
|
armedTotal += acceptDecision(store, it.d).armed;
|
|
1895
|
+
}
|
|
1830
1896
|
store.reindex();
|
|
1831
|
-
|
|
1897
|
+
if (publicGroundingChanged)
|
|
1898
|
+
refreshExistingGrounding(root, store); // committed grounding stays public-only
|
|
1832
1899
|
console.log(`✓ accepted ${ready.length} verified draft(s); ${armedTotal} tripwire(s) now blocking.`);
|
|
1833
1900
|
for (const it of ready)
|
|
1834
1901
|
console.log(` ${it.d.id} grounded=${it.synth.grounded ?? "?"} ${it.d.title}`);
|
|
1835
1902
|
}
|
|
1836
1903
|
}
|
|
1837
1904
|
else {
|
|
1838
|
-
const drafts =
|
|
1905
|
+
const drafts = decisions().filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
|
|
1839
1906
|
const { ready, scrutiny } = partitionReview(drafts, minGrounded);
|
|
1840
1907
|
if (!ready.length && !scrutiny.length) {
|
|
1841
1908
|
console.log("✓ No low-confidence drafts to review.");
|
|
@@ -1849,7 +1916,7 @@ program
|
|
|
1849
1916
|
}
|
|
1850
1917
|
if (scrutiny.length) {
|
|
1851
1918
|
console.log(`⚠ ${scrutiny.length} need scrutiny — unverified / low-grounded (lowest confidence first):\n`);
|
|
1852
|
-
const all =
|
|
1919
|
+
const all = decisions();
|
|
1853
1920
|
let dupCount = 0;
|
|
1854
1921
|
for (const it of scrutiny) {
|
|
1855
1922
|
printReviewItem(it);
|
|
@@ -1867,6 +1934,109 @@ program
|
|
|
1867
1934
|
}
|
|
1868
1935
|
store.close();
|
|
1869
1936
|
});
|
|
1937
|
+
// ---- auto-review (harness-driven triage) ----------------------------------
|
|
1938
|
+
/** One line per plan entry. */
|
|
1939
|
+
function printAutoEntry(e) {
|
|
1940
|
+
console.log(` ${e.d.id} ${e.d.title.slice(0, 66)}\n ${dim(e.reason)}`);
|
|
1941
|
+
}
|
|
1942
|
+
program
|
|
1943
|
+
.command("auto-review")
|
|
1944
|
+
.description("Harness-driven draft triage: delegate relevance to the coding-assistant CLI, then dedup, auto-confirm the verified+relevant, and delete duplicates/irrelevant. Dry-run unless --apply.")
|
|
1945
|
+
.option("--apply", "execute the plan (accept/delete). Without it, print the plan and change nothing.")
|
|
1946
|
+
.option("--min-grounded <n>", "grounded-ness threshold for the auto-accept gate", String(READY_MIN_GROUNDED))
|
|
1947
|
+
.option("--min-reject-confidence <n>", "minimum harness confidence to DELETE an irrelevant draft (else kept for a human)", "0.7")
|
|
1948
|
+
.option("--no-llm", "skip the harness judgment (dedup + grounding only — no relevance deletion)")
|
|
1949
|
+
.option("--private", "include local private/shared-overlay drafts; private drafts are never sent to an LLM judge")
|
|
1950
|
+
.action(async (opts) => {
|
|
1951
|
+
const { store, root } = storeFor();
|
|
1952
|
+
try {
|
|
1953
|
+
if (opts.private && !store.hasPrivate)
|
|
1954
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
1955
|
+
const minGrounded = Number.isFinite(Number(opts.minGrounded)) ? Number(opts.minGrounded) : READY_MIN_GROUNDED;
|
|
1956
|
+
const minRejectConfidence = Number.isFinite(Number(opts.minRejectConfidence)) ? Number(opts.minRejectConfidence) : 0.7;
|
|
1957
|
+
const all = opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
|
|
1958
|
+
// Same draft set `hunch review` / `hunch status` triage.
|
|
1959
|
+
const drafts = all.filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
|
|
1960
|
+
if (!drafts.length) {
|
|
1961
|
+
console.log("✓ No drafts to auto-review.");
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
// Delegate relevance to the harness (subscription CLI) — feature-detected,
|
|
1965
|
+
// and any per-draft failure degrades to "not judged" (kept for a human).
|
|
1966
|
+
const verdicts = new Map();
|
|
1967
|
+
if (opts.llm !== false && !opts.private) {
|
|
1968
|
+
const provider = await selectProvider();
|
|
1969
|
+
if (provider.judgeDraft) {
|
|
1970
|
+
// The candidate pool for duplicate_of / restatement: the LIVE, vouched records.
|
|
1971
|
+
const existing = all
|
|
1972
|
+
.filter((d) => d.provenance.source.includes("human_confirmed") && d.status !== "superseded" && d.status !== "rejected")
|
|
1973
|
+
.map((d) => ({ id: d.id, title: d.title, decision: d.decision }));
|
|
1974
|
+
console.log(`Judging ${drafts.length} draft(s) via ${provider.name} (subscription)…`);
|
|
1975
|
+
for (const d of drafts) {
|
|
1976
|
+
try {
|
|
1977
|
+
verdicts.set(d.id, await provider.judgeDraft(d, existing.filter((e) => e.id !== d.id)));
|
|
1978
|
+
}
|
|
1979
|
+
catch {
|
|
1980
|
+
/* transient / unparseable — leave unjudged, planner keeps it for a human */
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
else {
|
|
1985
|
+
console.log(dim("No subscription CLI available — relevance judgment skipped (dedup + grounding only)."));
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
else if (opts.private && opts.llm !== false) {
|
|
1989
|
+
console.log(dim("Private review stays local — harness judgment skipped (dedup + grounding only)."));
|
|
1990
|
+
}
|
|
1991
|
+
const plan = planAutoReview(drafts, all, verdicts, { minGrounded, minRejectConfidence });
|
|
1992
|
+
printAutoReviewPlan(plan);
|
|
1993
|
+
if (!opts.apply) {
|
|
1994
|
+
const n = planMutations(plan);
|
|
1995
|
+
console.log(`\n${dim(`Dry run — nothing changed. Re-run with --apply to ${n ? `apply ${n} change(s)` : "confirm (no changes)"}.`)}`);
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
// Apply: accept the verified+relevant, delete duplicates + irrelevant.
|
|
1999
|
+
let accepted = 0, deleted = 0, armedTotal = 0, publicAccepted = false;
|
|
2000
|
+
for (const e of plan.accept) {
|
|
2001
|
+
if (!store.getPrivateRec("decisions", e.d.id))
|
|
2002
|
+
publicAccepted = true;
|
|
2003
|
+
armedTotal += acceptDecision(store, e.d).armed;
|
|
2004
|
+
accepted++;
|
|
2005
|
+
}
|
|
2006
|
+
for (const e of [...plan.rejectDuplicate, ...plan.rejectIrrelevant]) {
|
|
2007
|
+
if ((opts.private ? store.deleteWhereItLives("decisions", e.d.id) : store.json.delete("decisions", e.d.id)))
|
|
2008
|
+
deleted++;
|
|
2009
|
+
}
|
|
2010
|
+
if (accepted || deleted) {
|
|
2011
|
+
store.reindex();
|
|
2012
|
+
if (publicAccepted)
|
|
2013
|
+
refreshExistingGrounding(root, store); // committed grounding stays public-only
|
|
2014
|
+
}
|
|
2015
|
+
console.log(`\n✓ auto-review applied: ${accepted} accepted${armedTotal ? ` (${armedTotal} tripwire(s) now blocking)` : ""}, ${deleted} deleted, ${plan.keep.length} kept for review.`);
|
|
2016
|
+
}
|
|
2017
|
+
finally {
|
|
2018
|
+
store.close();
|
|
2019
|
+
}
|
|
2020
|
+
});
|
|
2021
|
+
/** Print the four buckets of an auto-review plan (skipping empty ones). */
|
|
2022
|
+
function printAutoReviewPlan(plan) {
|
|
2023
|
+
if (plan.accept.length) {
|
|
2024
|
+
console.log(`\n✓ ACCEPT — verified, grounded, harness-relevant (${plan.accept.length}):`);
|
|
2025
|
+
plan.accept.forEach(printAutoEntry);
|
|
2026
|
+
}
|
|
2027
|
+
if (plan.rejectDuplicate.length) {
|
|
2028
|
+
console.log(`\n✗ DELETE (duplicate) — restates an accepted record (${plan.rejectDuplicate.length}):`);
|
|
2029
|
+
plan.rejectDuplicate.forEach(printAutoEntry);
|
|
2030
|
+
}
|
|
2031
|
+
if (plan.rejectIrrelevant.length) {
|
|
2032
|
+
console.log(`\n✗ DELETE (irrelevant) — harness judged not worth keeping (${plan.rejectIrrelevant.length}):`);
|
|
2033
|
+
plan.rejectIrrelevant.forEach(printAutoEntry);
|
|
2034
|
+
}
|
|
2035
|
+
if (plan.keep.length) {
|
|
2036
|
+
console.log(`\n⏳ KEEP for human review (${plan.keep.length}):`);
|
|
2037
|
+
plan.keep.forEach(printAutoEntry);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
1870
2040
|
// ---- mcp ------------------------------------------------------------------
|
|
1871
2041
|
program
|
|
1872
2042
|
.command("mcp")
|
|
@@ -2014,23 +2184,24 @@ program
|
|
|
2014
2184
|
.description("PR impact: the dependency + memory surface of a change — dependent files reached, invariants direct/near, and the decisions concerned. Read-only, advisory (gating is `hunch check`). Omit base and --commit to inspect staged changes.")
|
|
2015
2185
|
.argument("[base]", "diff against this base ref (e.g. origin/main) for a branch/PR")
|
|
2016
2186
|
.option("--commit <sha>", "impact of a single commit")
|
|
2187
|
+
.option("--working", "impact all working-tree edits vs HEAD (staged, unstaged, and untracked files)")
|
|
2017
2188
|
.action((base, opts) => {
|
|
2018
2189
|
const { store, root } = storeFor();
|
|
2019
2190
|
try {
|
|
2020
|
-
if (base && opts.commit)
|
|
2021
|
-
return fail("Pass
|
|
2191
|
+
if ((base && opts.commit) || (opts.working && (base || opts.commit)))
|
|
2192
|
+
return fail("Pass exactly one of [base] / --commit / --working (or omit all for staged changes).");
|
|
2022
2193
|
if (base && !revExists(base, root))
|
|
2023
2194
|
return fail(`base ref "${base}" does not resolve.`);
|
|
2024
2195
|
if (opts.commit && !revExists(opts.commit, root))
|
|
2025
2196
|
return fail(`commit "${opts.commit}" does not resolve.`);
|
|
2026
2197
|
store.reindex(); // reflect out-of-band JSON edits before reading the graph
|
|
2027
|
-
const files = opts.commit ? commitFiles(opts.commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
|
|
2028
|
-
const scope = opts.commit ? `commit ${opts.commit}` : base ? `${base}..HEAD` : "staged changes";
|
|
2198
|
+
const files = opts.commit ? commitFiles(opts.commit, root) : base ? rangeFiles(base, root) : opts.working ? workingFiles(root) : stagedFiles(root);
|
|
2199
|
+
const scope = opts.commit ? `commit ${opts.commit}` : base ? `${base}..HEAD` : opts.working ? "working changes" : "staged changes";
|
|
2029
2200
|
if (!files.length) {
|
|
2030
2201
|
console.log(`No changed files in ${scope}.`);
|
|
2031
2202
|
return;
|
|
2032
2203
|
}
|
|
2033
|
-
const diff = opts.commit ? commitDiff(opts.commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
|
|
2204
|
+
const diff = opts.commit ? commitDiff(opts.commit, root) : base ? rangeDiff(base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
|
|
2034
2205
|
console.log(renderImpact(store.prImpact(files, diff), scope));
|
|
2035
2206
|
}
|
|
2036
2207
|
finally {
|
|
@@ -2238,6 +2409,38 @@ program
|
|
|
2238
2409
|
store.close();
|
|
2239
2410
|
}
|
|
2240
2411
|
});
|
|
2412
|
+
// ---- repair-ref (atomic decision reference correction) --------------------
|
|
2413
|
+
program
|
|
2414
|
+
.command("repair-ref")
|
|
2415
|
+
.description("Atomically repair one exact file reference in a decision's scope and provenance evidence (never changes the decision itself).")
|
|
2416
|
+
.argument("<decision>", "decision id containing the stale reference")
|
|
2417
|
+
.requiredOption("--from <path>", "exact stale path to replace")
|
|
2418
|
+
.requiredOption("--to <path>", "exact current path (use private:<path> for a private-overlay file)")
|
|
2419
|
+
.option("--private", "require the decision to be in the configured private overlay")
|
|
2420
|
+
.action((id, opts) => {
|
|
2421
|
+
const { store } = storeFor();
|
|
2422
|
+
try {
|
|
2423
|
+
if (opts.from === opts.to)
|
|
2424
|
+
return fail("--from and --to must be different paths");
|
|
2425
|
+
if (opts.private && !store.hasPrivate)
|
|
2426
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
2427
|
+
// `getRec` is deliberately overlay-first. An explicit --private prevents a
|
|
2428
|
+
// same-id public record from being silently amended instead of private memory.
|
|
2429
|
+
const d = opts.private ? store.getPrivateRec("decisions", id) : store.getRec("decisions", id);
|
|
2430
|
+
if (!d)
|
|
2431
|
+
return fail(`decision "${id}" not found${opts.private ? " in the private overlay" : ""}`);
|
|
2432
|
+
const repaired = repairDecisionReference(d, opts.from, opts.to);
|
|
2433
|
+
if (!repaired)
|
|
2434
|
+
return fail(`decision "${id}" does not contain the exact reference "${opts.from}"`);
|
|
2435
|
+
store.putWhereItLives("decisions", repaired.decision);
|
|
2436
|
+
store.reindex();
|
|
2437
|
+
console.log(`✓ repaired ${id}: ${repaired.relatedFiles} related file reference(s) + ${repaired.evidence} provenance evidence reference(s).`);
|
|
2438
|
+
console.log(` ${opts.from} → ${opts.to}`);
|
|
2439
|
+
}
|
|
2440
|
+
finally {
|
|
2441
|
+
store.close();
|
|
2442
|
+
}
|
|
2443
|
+
});
|
|
2241
2444
|
// ---- compact (bound Hunch growth) -----------------------------------------
|
|
2242
2445
|
program
|
|
2243
2446
|
.command("compact")
|
|
@@ -2467,13 +2670,22 @@ function realpathNorm(p) {
|
|
|
2467
2670
|
function toRepoRel(root, abs) {
|
|
2468
2671
|
return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
|
|
2469
2672
|
}
|
|
2470
|
-
function emitContext(event, text) {
|
|
2471
|
-
|
|
2673
|
+
function emitContext(provider, event, text) {
|
|
2674
|
+
const output = contextHookOutput(provider, event, text);
|
|
2675
|
+
if (output)
|
|
2676
|
+
process.stdout.write(JSON.stringify(output));
|
|
2472
2677
|
}
|
|
2473
|
-
function emitDeny(reason) {
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2678
|
+
function emitDeny(provider, reason) {
|
|
2679
|
+
const result = denyHookOutput(provider, reason);
|
|
2680
|
+
if (result.output)
|
|
2681
|
+
process.stdout.write(JSON.stringify(result.output));
|
|
2682
|
+
if (result.stderr)
|
|
2683
|
+
process.stderr.write(`${result.stderr}\n`);
|
|
2684
|
+
if (result.exitCode !== undefined)
|
|
2685
|
+
process.exitCode = result.exitCode;
|
|
2686
|
+
}
|
|
2687
|
+
function emitStop(provider, reason) {
|
|
2688
|
+
process.stdout.write(JSON.stringify(stopHookOutput(provider, reason)));
|
|
2477
2689
|
}
|
|
2478
2690
|
program.parseAsync().catch((e) => {
|
|
2479
2691
|
try {
|