@davesheffer/hunch 1.45.1 → 1.46.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.
Files changed (64) hide show
  1. package/dist/cli/index.js +165 -33
  2. package/dist/cli/invocation.d.ts +14 -0
  3. package/dist/cli/invocation.js +23 -0
  4. package/dist/cli/serve.d.ts +2 -0
  5. package/dist/cli/serve.js +2 -1
  6. package/dist/cli/verify.d.ts +28 -0
  7. package/dist/cli/verify.js +265 -0
  8. package/dist/constitution/experiment.d.ts +1 -1
  9. package/dist/core/agenthook.d.ts +1 -0
  10. package/dist/core/agenthook.js +82 -26
  11. package/dist/core/boundedSpawn.d.ts +57 -0
  12. package/dist/core/boundedSpawn.js +321 -0
  13. package/dist/core/config.d.ts +6 -0
  14. package/dist/core/config.js +1 -0
  15. package/dist/core/delivery.d.ts +10 -0
  16. package/dist/core/delivery.js +117 -9
  17. package/dist/core/migrate.d.ts +10 -1
  18. package/dist/core/migrate.js +17 -6
  19. package/dist/core/pipeline.d.ts +9 -2
  20. package/dist/core/pipeline.js +13 -3
  21. package/dist/core/procstart.d.ts +17 -3
  22. package/dist/core/procstart.js +26 -5
  23. package/dist/core/projectDna.js +11 -1
  24. package/dist/core/relativeImports.js +6 -2
  25. package/dist/core/taskSelection.d.ts +1 -0
  26. package/dist/core/taskSelection.js +132 -11
  27. package/dist/core/testSelection.d.ts +122 -0
  28. package/dist/core/testSelection.js +588 -0
  29. package/dist/core/whyRecord.d.ts +47 -0
  30. package/dist/core/whyRecord.js +105 -0
  31. package/dist/extractors/git.d.ts +32 -0
  32. package/dist/extractors/git.js +206 -94
  33. package/dist/extractors/indexer.js +42 -6
  34. package/dist/extractors/languages.d.ts +9 -0
  35. package/dist/extractors/languages.js +28 -0
  36. package/dist/extractors/parse.d.ts +20 -0
  37. package/dist/extractors/parse.js +81 -5
  38. package/dist/extractors/workspaces.js +3 -1
  39. package/dist/integrations/health.d.ts +6 -0
  40. package/dist/integrations/health.js +33 -12
  41. package/dist/integrations/hooks.d.ts +4 -2
  42. package/dist/integrations/hooks.js +12 -2
  43. package/dist/integrations/mergeDriver.js +5 -3
  44. package/dist/integrations/optout.d.ts +3 -0
  45. package/dist/integrations/optout.js +17 -0
  46. package/dist/integrations/providers.d.ts +6 -2
  47. package/dist/integrations/providers.js +19 -2
  48. package/dist/integrations/team.js +6 -4
  49. package/dist/integrations/uninstall.d.ts +17 -4
  50. package/dist/integrations/uninstall.js +35 -13
  51. package/dist/mcp/server.js +179 -30
  52. package/dist/serve/app.js +9 -1
  53. package/dist/serve/config.js +5 -0
  54. package/dist/serve/writelock.d.ts +6 -5
  55. package/dist/serve/writelock.js +26 -21
  56. package/dist/store/hunchStore.d.ts +12 -1
  57. package/dist/store/hunchStore.js +86 -4
  58. package/dist/store/jsonStore.d.ts +24 -4
  59. package/dist/store/jsonStore.js +75 -26
  60. package/dist/store/stateBinding.js +17 -4
  61. package/dist/synthesis/provider.js +26 -78
  62. package/dist/synthesis/synthesize.js +1 -1
  63. package/package.json +2 -2
  64. package/server.json +2 -2
package/dist/cli/index.js CHANGED
@@ -22,13 +22,16 @@ import { fileURLToPath } from "node:url";
22
22
  import { Command } from "commander";
23
23
  import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir, realpathNorm, repoRelativeTarget } from "../core/paths.js";
24
24
  import { writeFileAtomic } from "../core/io.js";
25
+ import { boundedSpawnSync } from "../core/boundedSpawn.js";
25
26
  import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
26
27
  import { HUNCH_VERSION } from "../core/version.js";
27
28
  import { registerIntegrationCommands } from "./integrations.js";
28
29
  import { registerTaskReportCommands } from "./taskReport.js";
29
30
  import { registerStateCommands } from "./state.js";
30
- import { registerServeCommands } from "./serve.js";
31
+ import { DEFAULT_CONFIG as SERVE_DEFAULT_CONFIG_FILE, registerServeCommands } from "./serve.js";
32
+ import { readServeConfig } from "../serve/config.js";
31
33
  import { registerUpdateCommand } from "./update.js";
34
+ import { registerVerifyCommand } from "./verify.js";
32
35
  import { registerReviewMemoryCommands } from "./reviewMemory.js";
33
36
  import { detectInitiator, normalizeInitiator } from "../synthesis/initiator.js";
34
37
  import { inspectIntegrations, formatIntegrationHealth, integrationHealthFails, integrationSessionWarning } from "../integrations/health.js";
@@ -43,7 +46,7 @@ import { isParserLoadError, loadNativeTreeSitter } from "../extractors/nativeTre
43
46
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
44
47
  import { parseTestReport } from "../extractors/testreport.js";
45
48
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
46
- import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeCommits, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
49
+ import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeCommits, revExists, revParse, commitAndPushHunch, pullHunchStatus, pullHunchStatusDetailed, describePullFailure, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
47
50
  import { runbookDraft } from "../core/runbookDraft.js";
48
51
  import { parseMemoryLog } from "../core/memorylog.js";
49
52
  import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
@@ -62,7 +65,7 @@ import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, share
62
65
  import { installMergeDriver } from "../integrations/mergeDriver.js";
63
66
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade, memoryIgnoreRule, describeMemoryIgnoreRule } from "../integrations/gitignore.js";
64
67
  import { writeCiWorkflow } from "../integrations/ciAction.js";
65
- import { applyUninstall, formatUninstallPlan, isOptedOut, OPT_OUT_FILE, optOutMessage, planUninstall } from "../integrations/uninstall.js";
68
+ import { applyUninstall, formatUninstallPlan, isOptedOut, OPT_OUT_FILE, optOutMarkerAt, optOutMessage, planUninstall } from "../integrations/uninstall.js";
66
69
  import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
67
70
  import { HOOK_REMINDER } from "../core/hookText.js";
68
71
  import { measureFootprint } from "../core/footprint.js";
@@ -102,7 +105,7 @@ import { renderRecalledLine } from "../core/taskReportRender.js";
102
105
  import { closeHookTask, hookReportTaskId, isNotificationPrompt, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
103
106
  import { persistTaskRecord } from "../core/taskRecord.js";
104
107
  import { recordHookObservation } from "../core/hookObservations.js";
105
- import { contextHookOutput, deliversPromptEvent, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
108
+ import { contextHookOutput, deliversCommandOutcome, deliversPromptEvent, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
106
109
  import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, onLessonsDelivered, lessonReminder, unverifiedNag, } from "../core/pipeline.js";
107
110
  import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
108
111
  import { planAutoReview, planMutations } from "../core/autoreview.js";
@@ -147,6 +150,7 @@ import { partitionOf, stateHomeFor } from "../store/stateBinding.js";
147
150
  import { scopePath } from "../core/stateContract.js";
148
151
  import { movePublicMemoryToPrivate } from "../store/privateMigrate.js";
149
152
  import { ENTITY_KINDS } from "../core/types.js";
153
+ import { formatWhyRecord } from "../core/whyRecord.js";
150
154
  import { planCompaction } from "../store/compact.js";
151
155
  import { repairDecisionReference } from "../core/refrepair.js";
152
156
  import { resolveInvocation, dim, synthesisStatusLines, maybeWarnOllamaContext } from "./invocation.js";
@@ -187,6 +191,7 @@ registerTaskReportCommands(program, () => { const { store, root } = storeFor();
187
191
  registerServeCommands(program);
188
192
  registerStateCommands(program);
189
193
  registerUpdateCommand(program);
194
+ registerVerifyCommand(program);
190
195
  registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
191
196
  const { store, root } = storeFor();
192
197
  if (!repositoryUsesRemote(root, `https://github.com/${repository}.git`)) {
@@ -227,6 +232,103 @@ registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
227
232
  // run: a leftover driver that exits without writing %A would let git drop `theirs`.
228
233
  const OPT_OUT_ALLOWED = new Set(["uninstall", "doctor", "help", "merge-driver", "merge-driver-grounding"]);
229
234
  const OPT_OUT_QUIET = new Set(["hook", "sync", "check", "grounding", "repair-provenance", "workspaces"]);
235
+ /** Locations a command writes to (#488), keyed by command path: an opted-out repository
236
+ * must never receive them, so their repository is gated IN ADDITION to the cwd's.
237
+ * `base` is what the command itself resolves a relative value against: the cwd's
238
+ * repository root (`findRoot`), Git's top level (`verify`), or the cwd; `join` marks a
239
+ * command that joins the value onto that base even when absolute (`export-adr`).
240
+ * `checkout` marks a command that also writes into the MAIN checkout (the overlay
241
+ * anchor) and the git common dir (the shared-overlay pointer, the shared git hooks),
242
+ * so both are gated; with no target (`init`) only those are. */
243
+ const OPT_OUT_OUTPUTS = {
244
+ "init": { base: "root", checkout: true },
245
+ "private": { arg: 0, base: "root", checkout: true },
246
+ "shared": { arg: 0, base: "root", checkout: true },
247
+ "worktree": { arg: 0, base: "root", checkout: true },
248
+ "export-adr": { arg: 0, base: "root", join: true },
249
+ "review-memory auto": { option: "output", base: "cwd" },
250
+ "verify": { option: "map", base: "toplevel" },
251
+ };
252
+ /** Where `hunch private`/`shared` anchor the default overlay store: the MAIN worktree
253
+ * root, shared by the write and the opt-out gate so they cannot diverge. */
254
+ function overlayAnchor(root) {
255
+ return mainWorktreeRoot(root);
256
+ }
257
+ /** Git's top level for the cwd, as `hunch verify` resolves it; findRoot() outside Git. */
258
+ function gitToplevel() {
259
+ try {
260
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }).trim() || findRoot();
261
+ }
262
+ catch {
263
+ return findRoot();
264
+ }
265
+ }
266
+ /** The marker gating `path` (the cwd, or a location a command writes to). Its own store
267
+ * or repository root (`findRoot`: git-less stores included) counts first; inside a
268
+ * repository the walk then passes any nested `.hunch` store up to the repository
269
+ * (#488). A stray marker in a non-store, non-repository ancestor (e.g. ~) gates nothing. */
270
+ function markerFor(path) {
271
+ const real = realpathNorm(path);
272
+ const root = findRoot(real);
273
+ if (isOptedOut(root))
274
+ return join(root, OPT_OUT_FILE);
275
+ return optOutMarkerAt(real, { withinRepo: true });
276
+ }
277
+ /** Every location whose repository the opt-out gate judges for this invocation (#458,
278
+ * #488). A `--root` or a `serve` config names the repository the command acts on, so
279
+ * it REPLACES the cwd; `serve` also gates every partition root its config names (an
280
+ * unreadable config gates its directory only; the command itself then fails). An
281
+ * output location only ADDS to the cwd. */
282
+ function optOutTargets(top, action) {
283
+ const path = [];
284
+ for (let c = action; c && c !== program; c = c.parent)
285
+ path.unshift(c.name());
286
+ const given = (v) => typeof v === "string" && v.trim() !== "";
287
+ const actionOpts = action.opts();
288
+ const targets = [];
289
+ let cwd = true;
290
+ if (given(actionOpts.root)) {
291
+ targets.push(resolve(actionOpts.root));
292
+ cwd = false;
293
+ }
294
+ if (top.name() === "serve" && (path[1] === "init" || cwd)) {
295
+ // `serve` and `serve init` both take --config; the command reads the parent's first.
296
+ const configOpt = top.opts().config ?? actionOpts.config;
297
+ const configFile = resolve(given(configOpt) ? configOpt : SERVE_DEFAULT_CONFIG_FILE);
298
+ targets.push(dirname(configFile));
299
+ if (cwd) {
300
+ try {
301
+ for (const p of readServeConfig(configFile).partitions)
302
+ targets.push(p.root);
303
+ }
304
+ catch { /* the command reports it */ }
305
+ }
306
+ cwd = false;
307
+ }
308
+ // Output locations follow the cwd rule: a marker gates them only inside a repository,
309
+ // never from a non-repository ancestor (bare / --separate-git-dir common dirs included).
310
+ const outputs = [];
311
+ const out = OPT_OUT_OUTPUTS[path.join(" ")];
312
+ if (out) {
313
+ const value = out.option !== undefined ? actionOpts[out.option] : action.processedArgs[out.arg ?? 0];
314
+ if (given(value)) {
315
+ const base = out.base === "root" ? findRoot() : out.base === "toplevel" ? gitToplevel() : process.cwd();
316
+ // `join`: the path the command writes, plus the absolute path the user named.
317
+ if (out.join)
318
+ outputs.push(join(base, toPosixTarget(value)));
319
+ outputs.push(resolve(base, value));
320
+ }
321
+ if (out.checkout) {
322
+ const root = findRoot();
323
+ const common = gitCommonDir(root);
324
+ // Only a real linked worktree shares a common dir with a main checkout; a submodule's
325
+ // common dir (super/.git/modules/x) must not pull the superproject's marker in.
326
+ if (common && mainWorktreeRoot(root) !== root)
327
+ outputs.push(overlayAnchor(root), resolve(root, common));
328
+ }
329
+ }
330
+ return { targets, outputs, cwd };
331
+ }
230
332
  program.hook("preAction", (_thisCommand, actionCommand) => {
231
333
  let top = actionCommand;
232
334
  while (top.parent && top.parent !== program)
@@ -234,10 +336,26 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
234
336
  const name = top.name();
235
337
  if (OPT_OUT_ALLOWED.has(name))
236
338
  return;
237
- if (!isOptedOut(findRoot()))
339
+ // Gate the repository each location acts on (#458, #488): its real path is walked up
340
+ // through its ancestors to its repository, past any nested `.hunch` store (fail
341
+ // closed, so a cwd inside a nested store is gated too), and the refusal names the
342
+ // marker that actually gates it.
343
+ const { targets, outputs, cwd } = optOutTargets(top, actionCommand);
344
+ let marker = cwd ? markerFor(process.cwd()) : null;
345
+ for (const target of targets) {
346
+ if (marker)
347
+ break;
348
+ marker = optOutMarkerAt(realpathNorm(target));
349
+ }
350
+ for (const target of outputs) {
351
+ if (marker)
352
+ break;
353
+ marker = markerFor(target);
354
+ }
355
+ if (!marker)
238
356
  return;
239
357
  if (name !== "hook")
240
- console.error(optOutMessage());
358
+ console.error(optOutMessage(marker));
241
359
  process.exit(OPT_OUT_QUIET.has(name) ? 0 : 1);
242
360
  });
243
361
  // Read an already-known update and schedule any registry refresh in a detached
@@ -301,6 +419,7 @@ function openTeamStore(root, opts = {}) {
301
419
  // from their durable local overlay when the network is temporarily down, but a
302
420
  // strict guard must prove it saw the advertised remote before it may pass.
303
421
  let pullStatus = null;
422
+ let pullDetail;
304
423
  if (teamAdvertised && store.mode === "shared" && store.privateDir) {
305
424
  const remote = advertisedTeamRemoteContract(root, dirname(store.privateDir));
306
425
  if (!remote || !remote.verify()) {
@@ -309,10 +428,9 @@ function openTeamStore(root, opts = {}) {
309
428
  throw new Error("the advertised team memory route could not be pinned for this command");
310
429
  }
311
430
  pinSharedRemote(store, remote);
312
- pullStatus = pullHunchStatus(store.privateDir, {
313
- timeoutMs: 5_000,
431
+ ({ status: pullStatus, detail: pullDetail } = pullHunchStatusDetailed(store.privateDir, {
314
432
  remote,
315
- });
433
+ }));
316
434
  // The route may be coherently rewritten while the bounded pull is blocked.
317
435
  // No handler — including an ordinary non-strict read — may run after the
318
436
  // graph epoch that admitted this command stops verifying.
@@ -322,12 +440,12 @@ function openTeamStore(root, opts = {}) {
322
440
  throw new Error("the advertised team memory route changed while this command was refreshing; refusing to serve or mutate stale memory");
323
441
  }
324
442
  }
325
- return { store, teamWired, teamPullStatus: opts.requireFreshTeamMemory ? pullStatus : null };
443
+ return { store, teamWired, teamPullStatus: opts.requireFreshTeamMemory ? pullStatus : null, teamPullDetail: opts.requireFreshTeamMemory ? pullDetail : undefined };
326
444
  }
327
445
  function storeFor(opts = {}) {
328
446
  const root = findRoot();
329
- const { store, teamPullStatus } = openTeamStore(root, opts);
330
- return { store, root, teamPullStatus };
447
+ const { store, teamPullStatus, teamPullDetail } = openTeamStore(root, opts);
448
+ return { store, root, teamPullStatus, teamPullDetail };
331
449
  }
332
450
  function pumpMemoryHome(store, root, home, message) {
333
451
  flushMemoryHome(store, hunchPaths(root).hunch, home, message);
@@ -539,8 +657,8 @@ program
539
657
  }
540
658
  // Windows self-heal: if an earlier global `claude mcp add` left a drive-letter
541
659
  // case-split in ~/.claude.json, merge it so hunch resolves under either casing.
542
- // No-op (silent) off Windows.
543
- reportClaudeConfigHeal();
660
+ // No-op (silent) off Windows; the no-op line belongs to --details (#493).
661
+ reportClaudeConfigHeal(say);
544
662
  // Worktree-seamless: register any configured overlay at the git common dir so EVERY
545
663
  // worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
546
664
  // and note when we're initializing inside a linked worktree (memory is shared, not separate).
@@ -971,13 +1089,12 @@ function discoverSharedRemote(remoteUrl) {
971
1089
  && remoteUrl.startsWith(prefix))
972
1090
  return null;
973
1091
  }
974
- const listed = spawnSync("git", [
1092
+ // Bounded so a timeout kills git's whole tree, not just the root (#498).
1093
+ const listed = boundedSpawnSync("git", [
975
1094
  "-C", probe,
976
1095
  "-c", `core.hooksPath=${hooksDir}`,
977
1096
  "ls-remote", "--refs", "--heads", "--upload-pack=git-upload-pack", remoteUrl,
978
1097
  ], {
979
- encoding: "utf8",
980
- stdio: ["ignore", "pipe", "ignore"],
981
1098
  env: boundedTeamGitEnv(),
982
1099
  timeout: 15_000,
983
1100
  });
@@ -1054,7 +1171,6 @@ function configureExistingValidatedOverlay(destRoot, codeRoot, requestedRemote,
1054
1171
  }
1055
1172
  else {
1056
1173
  const pulled = pullHunchStatus(hunchDir, {
1057
- timeoutMs: 5_000,
1058
1174
  remote: contract,
1059
1175
  allowUnrelatedHistories: true,
1060
1176
  });
@@ -1311,7 +1427,7 @@ function configureOverlay(dir, opts, mode) {
1311
1427
  // Anchor the default store at the MAIN worktree root: a linked worktree can be
1312
1428
  // `git worktree remove`d, which would take the store (and every other worktree's
1313
1429
  // absolute pointer to it) down with it. An explicit [dir] still resolves from here.
1314
- const anchor = mainWorktreeRoot(root);
1430
+ const anchor = overlayAnchor(root);
1315
1431
  if (opts.repo) {
1316
1432
  const dest = join(anchor, ".hunch-private");
1317
1433
  // Canonicalize once in the invocation context so clone, attach, fetch, and
@@ -3783,7 +3899,7 @@ program
3783
3899
  program
3784
3900
  .command("why")
3785
3901
  .description("Explain why a file/symbol is the way it is (decisions, bugs, constraints).")
3786
- .argument("<target>", "file path or symbol name")
3902
+ .argument("<target>", "file path, symbol name, or record id (dec_/con_/bug_/fnd_/cmp_)")
3787
3903
  .option("--as-of <ref>", "time-travel: what was believed as of a commit/tag/branch (e.g. v0.7.0, HEAD~5)")
3788
3904
  .action((target, opts) => {
3789
3905
  const { store, root } = storeFor();
@@ -3791,6 +3907,13 @@ program
3791
3907
  if (opts.asOf && !asOf)
3792
3908
  return fail(`could not resolve --as-of "${opts.asOf}" to a commit (need a git repo and a valid ref)`);
3793
3909
  const w = store.why(target, { asOf });
3910
+ if (w.byId?.record) {
3911
+ if (asOf)
3912
+ console.log(`Why "${target}" (as of ${opts.asOf} — ${asOf.slice(0, 10)}):\n`);
3913
+ console.log(formatWhyRecord(w.byId).join("\n"));
3914
+ store.close();
3915
+ return;
3916
+ }
3794
3917
  const staleIds = new Set(store.staleness((f) => lastChangeDate(f, root)).map((s) => s.id));
3795
3918
  const drift = (id) => (staleIds.has(id) ? " ⚠STALE" : "");
3796
3919
  console.log(asOf ? `Why "${target}" (as of ${opts.asOf} — ${asOf.slice(0, 10)}):\n` : `Why "${target}":\n`);
@@ -3810,7 +3933,7 @@ program
3810
3933
  console.log(` • ${b.id} [${b.status}] ${b.title} — ${b.root_cause}`);
3811
3934
  }
3812
3935
  if (!w.decisions.length && !w.constraints.length && !w.bugs.length) {
3813
- console.log("(No recorded decisions/bugs/constraints yet. Try `hunch backfill` or make a commit.)");
3936
+ console.log(w.byId ? formatWhyRecord(w.byId).join("\n") : "(No recorded decisions/bugs/constraints yet. Try `hunch backfill` or make a commit.)");
3814
3937
  }
3815
3938
  if (w.symbols.length)
3816
3939
  console.log(`\nSYMBOLS: ${w.symbols.map((s) => `${s.name} [fan-in ${s.metrics.fan_in}, churn ${s.metrics.churn_90d}]`).join(", ")}`);
@@ -4233,9 +4356,10 @@ program
4233
4356
  // interleaved section prints are suppressed for this format.
4234
4357
  const sarif = opts.format === "sarif";
4235
4358
  const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
4236
- const { store, root, teamPullStatus } = storeFor({ requireFreshTeamMemory: !!opts.strict && !opts.publicOnly });
4359
+ const { store, root, teamPullStatus, teamPullDetail } = storeFor({ requireFreshTeamMemory: !!opts.strict && !opts.publicOnly });
4237
4360
  const teamFreshnessFailure = teamPullStatus !== null && !teamPullIsFresh(teamPullStatus)
4238
4361
  ? `strict check could not refresh the advertised team memory (${teamPullStatus}); refusing a stale pass` +
4362
+ (teamPullDetail ? ` (${describePullFailure(teamPullDetail)})` : "") +
4239
4363
  (teamPullStatus === "unconfigured" ? " — the shared memory remote has no published branch while this clone has memory; publish it with `hunch shared --sync`" : "")
4240
4364
  : null;
4241
4365
  // An empty team remote is in sync with a never-committed clone, but it is also what
@@ -5466,7 +5590,11 @@ program
5466
5590
  // before implementation. Firm/strict may deny two edits per prompt, then
5467
5591
  // fail open so a malformed or unavailable probe can never deadlock work.
5468
5592
  if ((firmness === "firm" || firmness === "strict") && evt.session_id && pipelineEnabled() && targets.some((t) => isProductPath(t.target))) {
5469
- const baseline = beforeEditProbeVerdict(loadPipelineState(evt.session_id));
5593
+ const baseline = beforeEditProbeVerdict(loadPipelineState(evt.session_id), { commandOutcomes: deliversCommandOutcome(provider) });
5594
+ // Unverifiable on this host: the edit proceeds and the hook says why on
5595
+ // stderr (stdout stays free for the grounding/deny response below).
5596
+ if (baseline.unverifiable && baseline.reason)
5597
+ process.stderr.write(`${baseline.reason}\n`);
5470
5598
  if (baseline.block) {
5471
5599
  savePipelineState(evt.session_id, baseline.state);
5472
5600
  emitDeny(provider, baseline.reason ?? "Hunch evidence gate — establish the pre-edit probe baseline first.");
@@ -6069,12 +6197,15 @@ program
6069
6197
  return;
6070
6198
  }
6071
6199
  const res = target.persistMigration();
6072
- writeManifest(paths, SCHEMA_VERSION);
6200
+ writeManifest(paths, res.version); // held at `from` while any record failed to migrate
6073
6201
  store.reindex();
6074
- pumpMemoryHome(store, root, store.unified ? "private" : "public", `hunch: migrate schema v${from} to v${SCHEMA_VERSION}`);
6075
- console.log(`✓ Migrated v${from} → v${SCHEMA_VERSION}: ${res.migrated} record(s) upgraded.`);
6202
+ pumpMemoryHome(store, root, store.unified ? "private" : "public", `hunch: migrate schema v${from} to v${res.version}`);
6203
+ console.log(`✓ Migrated v${from} → v${res.version}: ${res.migrated} record(s) upgraded.`);
6076
6204
  if (res.skipped) {
6077
- console.warn(`⚠ ${res.skipped} record(s) could NOT be migrated and will no longer load. They are preserved on disk in their old shape under .hunch/ for manual recovery.`);
6205
+ console.warn(`⚠ ${res.skipped} record(s) could not be migrated. They are preserved on disk in their old shape under .hunch/ for manual recovery; the schema stays at v${res.version}, so re-running \`hunch migrate\` after a fix completes the migration to v${SCHEMA_VERSION}.`);
6206
+ }
6207
+ if (res.invalid) {
6208
+ console.warn(`⚠ ${res.invalid} record(s) are invalid or unreadable and no pending migration applies to them. They were left untouched under .hunch/; fix them by hand.`);
6078
6209
  }
6079
6210
  store.close();
6080
6211
  });
@@ -7585,26 +7716,27 @@ function rel(root, p) {
7585
7716
  }
7586
7717
  /** Run the Windows ~/.claude.json drive-letter case-split heal and print what it
7587
7718
  * did. Silent + no-op off Windows. A parse refusal is surfaced as a warning, never
7588
- * thrown out of doctor/init (those commands must still complete). */
7589
- function reportClaudeConfigHeal() {
7719
+ * thrown out of doctor/init (those commands must still complete). `say` lets `init`
7720
+ * route the lines into its buffered output (shown with --details; warnings always). */
7721
+ function reportClaudeConfigHeal(say = (text) => console.log(text)) {
7590
7722
  let res;
7591
7723
  try {
7592
7724
  res = healClaudeConfigCaseSplit();
7593
7725
  }
7594
7726
  catch (e) {
7595
- console.log(` ⚠ Claude config: ${e.message}`);
7727
+ say(` ⚠ Claude config: ${e.message}`);
7596
7728
  return;
7597
7729
  }
7598
7730
  if (!res.applicable)
7599
7731
  return; // non-Windows: the case-split bug can't occur
7600
7732
  if (!res.changed) {
7601
- console.log(dim(`Claude config: no drive-letter project split (${res.file})`));
7733
+ say(dim(`Claude config: no drive-letter project split (${res.file})`));
7602
7734
  return;
7603
7735
  }
7604
7736
  for (const g of res.groups) {
7605
- console.log(`✓ healed Claude Code project case-split: mirrored [${g.servers.join(", ")}] across ${g.casings.join(" · ")}`);
7737
+ say(`✓ healed Claude Code project case-split: mirrored [${g.servers.join(", ")}] across ${g.casings.join(" · ")}`);
7606
7738
  }
7607
- console.log(dim(` ↳ backup: ${res.backup}`));
7739
+ say(dim(` ↳ backup: ${res.backup}`));
7608
7740
  }
7609
7741
  function fail(msg) {
7610
7742
  console.error(`error: ${msg}`);
@@ -41,3 +41,17 @@ export declare function classifyEntry(entry: string): {
41
41
  installed: boolean;
42
42
  };
43
43
  export declare function resolveInvocation(): ResolvedInvocation;
44
+ /** The command prefix for the git merge drivers. git runs a driver with the
45
+ * repository being merged as its cwd, so the dev-mode `npx tsx <entry>` would
46
+ * resolve tsx from THAT repo (or fetch it from the registry), not from Hunch's
47
+ * own checkout (#492). In dev mode, launch the absolute node binary against
48
+ * this checkout's tsx cli.mjs, the same shape selfBuildInvocation gives the
49
+ * git hooks (src/integrations/hooks.ts); every other invocation (published,
50
+ * dist, or a caller-supplied one) passes through unchanged. */
51
+ export declare function mergeDriverShell(invShell: string): string;
52
+ /** Filesystem paths, never a file URL: git expands `%`-escapes in the driver
53
+ * command, so a percent-encoded URL (any non-ASCII checkout path) would be
54
+ * corrupted. POSIX single quotes keep `$`, backticks and `"` literal under the
55
+ * /bin/sh git runs drivers with on every platform, and a literal `%` in a path
56
+ * is doubled so git expands it back to one. */
57
+ export declare function devMergeDriverShell(execPath: string, tsxCli: string, entry: string): string;
@@ -125,4 +125,27 @@ export function resolveInvocation() {
125
125
  installed,
126
126
  };
127
127
  }
128
+ /** The command prefix for the git merge drivers. git runs a driver with the
129
+ * repository being merged as its cwd, so the dev-mode `npx tsx <entry>` would
130
+ * resolve tsx from THAT repo (or fetch it from the registry), not from Hunch's
131
+ * own checkout (#492). In dev mode, launch the absolute node binary against
132
+ * this checkout's tsx cli.mjs, the same shape selfBuildInvocation gives the
133
+ * git hooks (src/integrations/hooks.ts); every other invocation (published,
134
+ * dist, or a caller-supplied one) passes through unchanged. */
135
+ export function mergeDriverShell(invShell) {
136
+ const inv = resolveInvocation();
137
+ if (inv.installed || !inv.shell.startsWith("npx tsx ") || invShell !== inv.shell)
138
+ return invShell;
139
+ const entry = fileURLToPath(import.meta.url).replace(/invocation\.ts$/, "index.ts");
140
+ return devMergeDriverShell(process.execPath, fileURLToPath(import.meta.resolve("tsx/cli")), entry);
141
+ }
142
+ /** Filesystem paths, never a file URL: git expands `%`-escapes in the driver
143
+ * command, so a percent-encoded URL (any non-ASCII checkout path) would be
144
+ * corrupted. POSIX single quotes keep `$`, backticks and `"` literal under the
145
+ * /bin/sh git runs drivers with on every platform, and a literal `%` in a path
146
+ * is doubled so git expands it back to one. */
147
+ export function devMergeDriverShell(execPath, tsxCli, entry) {
148
+ const quote = (s) => `'${s.replace(/'/g, "'\\''")}'`.replace(/%/g, "%%");
149
+ return [execPath, tsxCli, entry].map(quote).join(" ");
150
+ }
128
151
  //# sourceMappingURL=invocation.js.map
@@ -13,4 +13,6 @@ export declare function compactPartition(root: string, scope: {
13
13
  floor_seq: number;
14
14
  head_seq: number;
15
15
  }>;
16
+ /** The serve config file used when no --config is given, resolved against the cwd. */
17
+ export declare const DEFAULT_CONFIG = "hunch-serve.json";
16
18
  export declare function registerServeCommands(program: Command): void;
package/dist/cli/serve.js CHANGED
@@ -32,8 +32,9 @@ export async function compactPartition(root, scope, keep, lockOptions = {}) {
32
32
  store.close();
33
33
  }
34
34
  }
35
+ /** The serve config file used when no --config is given, resolved against the cwd. */
36
+ export const DEFAULT_CONFIG = "hunch-serve.json";
35
37
  export function registerServeCommands(program) {
36
- const DEFAULT_CONFIG = "hunch-serve.json";
37
38
  const serve = program.command("serve")
38
39
  .description("Serve nuryel.state/1 over HTTP for organization / team / user / repository partitions (binds 127.0.0.1; put it behind SSH or a reverse proxy)")
39
40
  .option("--config <file>", `serve config (nuryel.serve-config/1); default ${DEFAULT_CONFIG}`)
@@ -0,0 +1,28 @@
1
+ import type { Command } from "commander";
2
+ import { type ImpactMap } from "../core/testSelection.js";
3
+ export declare const DEFAULT_IMPACT_MAP = ".hunch-cache/test-impact.json";
4
+ /** Split a command template into argv tokens; double/single quotes group. */
5
+ export declare function tokenize(template: string): string[];
6
+ /** The runner argv (never a shell). A configured template wins (`{files}` expands
7
+ * to one argv entry per file); otherwise this repo's runner when present, else
8
+ * `npm test` (full) / `npm test -- <files>`. */
9
+ export declare function runnerArgv(root: string, template: string | undefined, files: string[], extra: string[], full: boolean): string[];
10
+ /** Run each test in its own runner process under NODE_V8_COVERAGE (inherited
11
+ * by spawned CLI/MCP children) and record the repo files each executed. An
12
+ * entry is `complete` only when the test file passed and produced coverage;
13
+ * failing tests still record their (partial) files. `only` merges into the
14
+ * old map, keeping each untouched entry's own commit stamp. */
15
+ export declare function refreshImpactMap(opts: {
16
+ root: string;
17
+ tests: string[];
18
+ files: string[];
19
+ template?: string;
20
+ mapPath: string;
21
+ only?: string[];
22
+ concurrency?: number;
23
+ log?: (line: string) => void;
24
+ }): Promise<{
25
+ map: ImpactMap;
26
+ failed: string[];
27
+ }>;
28
+ export declare function registerVerifyCommand(program: Command): void;