@davesheffer/hunch 1.39.1 → 1.39.3

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 +164 -55
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/client/state.d.ts +2 -1
  5. package/dist/client/state.js +1 -0
  6. package/dist/core/agenthook.js +7 -3
  7. package/dist/core/capturetoken.d.ts +30 -3
  8. package/dist/core/capturetoken.js +29 -3
  9. package/dist/core/correction.d.ts +10 -4
  10. package/dist/core/correction.js +19 -11
  11. package/dist/core/countersign.d.ts +28 -0
  12. package/dist/core/countersign.js +50 -0
  13. package/dist/core/docanchors.js +16 -2
  14. package/dist/core/glob.d.ts +19 -0
  15. package/dist/core/glob.js +37 -0
  16. package/dist/core/paths.d.ts +27 -5
  17. package/dist/core/paths.js +51 -6
  18. package/dist/core/procstart.d.ts +94 -0
  19. package/dist/core/procstart.js +244 -0
  20. package/dist/core/reviewqueue.js +6 -1
  21. package/dist/core/served.d.ts +6 -3
  22. package/dist/core/served.js +10 -6
  23. package/dist/core/spawnCommand.js +41 -9
  24. package/dist/core/stateHttp.d.ts +1 -1
  25. package/dist/core/stateHttp.js +3 -1
  26. package/dist/core/taskReport.d.ts +2 -0
  27. package/dist/core/taskReport.js +46 -11
  28. package/dist/core/taskReportEvidence.js +42 -6
  29. package/dist/core/taskReportHook.js +14 -2
  30. package/dist/core/topics.js +1 -1
  31. package/dist/core/workspace.d.ts +9 -0
  32. package/dist/core/workspace.js +15 -0
  33. package/dist/extractors/git.d.ts +5 -0
  34. package/dist/extractors/git.js +87 -10
  35. package/dist/extractors/indexer.d.ts +10 -0
  36. package/dist/extractors/indexer.js +17 -1
  37. package/dist/extractors/k8sManifest.js +104 -16
  38. package/dist/integrations/hookmatch.d.ts +25 -0
  39. package/dist/integrations/hookmatch.js +58 -0
  40. package/dist/integrations/hooks.d.ts +38 -7
  41. package/dist/integrations/hooks.js +250 -24
  42. package/dist/integrations/providers.js +2 -17
  43. package/dist/integrations/scaffold.d.ts +1 -1
  44. package/dist/integrations/scaffold.js +25 -22
  45. package/dist/integrations/workspaceLedger.d.ts +10 -0
  46. package/dist/integrations/workspaceLedger.js +9 -1
  47. package/dist/mcp/server.d.ts +10 -0
  48. package/dist/mcp/server.js +131 -64
  49. package/dist/serve/app.d.ts +4 -0
  50. package/dist/serve/app.js +56 -36
  51. package/dist/serve/writelock.d.ts +21 -1
  52. package/dist/serve/writelock.js +157 -16
  53. package/dist/store/hunchStore.js +41 -11
  54. package/dist/store/jsonStore.d.ts +6 -4
  55. package/dist/store/jsonStore.js +225 -32
  56. package/dist/store/merge.d.ts +7 -4
  57. package/dist/store/merge.js +65 -5
  58. package/dist/store/privateMigrate.js +10 -0
  59. package/dist/store/stateBinding.js +28 -7
  60. package/dist/wiki/adopt.js +13 -2
  61. package/dist/wiki/wiki.d.ts +7 -0
  62. package/dist/wiki/wiki.js +19 -8
  63. package/package.json +1 -1
  64. package/server.json +2 -2
package/dist/cli/index.js CHANGED
@@ -14,13 +14,13 @@
14
14
  * doctor environment diagnostics
15
15
  */
16
16
  import "./preflight.js"; // MUST stay the first import — Node-version gate before node:sqlite loads
17
- import { chmodSync, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, writeFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, rmdirSync, symlinkSync } from "node:fs";
17
+ import { chmodSync, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, rmdirSync, symlinkSync } from "node:fs";
18
18
  import { execFileSync, spawnSync } from "node:child_process";
19
19
  import { join, relative, dirname, basename, resolve, isAbsolute } from "node:path";
20
20
  import { tmpdir } from "node:os";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { Command } from "commander";
23
- import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir } from "../core/paths.js";
23
+ import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir, realpathNorm, repoRelativeTarget } from "../core/paths.js";
24
24
  import { writeFileAtomic } from "../core/io.js";
25
25
  import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
26
26
  import { HUNCH_VERSION } from "../core/version.js";
@@ -36,7 +36,7 @@ import { publishedStatus } from "../integrations/registry.js";
36
36
  import { HunchStore } from "../store/hunchStore.js";
37
37
  import { JsonStore } from "../store/jsonStore.js";
38
38
  import { selectEmbedder } from "../store/embedder.js";
39
- import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
39
+ import { assertCompleteRepoScan, indexRepo, mergeScannedEdges, scanRepo } from "../extractors/indexer.js";
40
40
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
41
41
  import { parseTestReport } from "../extractors/testreport.js";
42
42
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
@@ -52,7 +52,7 @@ import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
52
52
  import { extractInlineIntent } from "../extractors/comments.js";
53
53
  import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
54
54
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
55
- import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, formatHookInstall } from "../integrations/hooks.js";
55
+ import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, hookInvocationLines, formatHookInstall } from "../integrations/hooks.js";
56
56
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
57
57
  import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
58
58
  import { installMergeDriver } from "../integrations/mergeDriver.js";
@@ -80,7 +80,7 @@ import { diffProjectDna } from "../core/projectDnaDelta.js";
80
80
  import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
81
81
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness, workspacesConfig } from "../core/config.js";
82
82
  import { loadOrCreateMachine, setMachineLabel, machineFile, labelLeaksIdentity } from "../core/machine.js";
83
- import { worktreeRows, branchRows } from "../core/workspace.js";
83
+ import { worktreeRows, branchRows, publicWorkspaceRemovalRecipe } from "../core/workspace.js";
84
84
  import { workspaceLedgerView, recordWorkspaceSnapshot, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, prunePlanFor, renderPrunePlan, applyPrune, confirmPrune, pruneConfirmQuestion } from "../integrations/workspaceLedger.js";
85
85
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
86
86
  import { isHumanConfirmed } from "../core/strictgate.js";
@@ -88,7 +88,7 @@ import { appendEvent, readEvents } from "../core/events.js";
88
88
  import { computeStats, formatStats } from "../core/stats.js";
89
89
  import { injectionMode, resetSessionInjections } from "../core/hookcache.js";
90
90
  import { recordServed, servedSummary } from "../core/served.js";
91
- import { recordTaskDelivery, reportActivity, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
91
+ import { recordTaskDelivery, reportActivity, reportHash, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
92
92
  import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
93
93
  import { renderRecalledLine } from "../core/taskReportRender.js";
94
94
  import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
@@ -102,10 +102,11 @@ import { loadGoldenSet, evaluateRetrieval, evaluateTraversalLift } from "../eval
102
102
  import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
103
103
  import { DRIFT_KINDS, computeDrift } from "../core/drift.js";
104
104
  import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
105
- import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
105
+ import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData, unconfirmedRoadmapMarker } from "../wiki/wiki.js";
106
106
  import { adoptProsePrompt } from "../wiki/adopt.js";
107
107
  import { topicCollisions, isInForce, liveForTopic } from "../core/topics.js";
108
108
  import { ADR_DIR_CANDIDATES, ADR_FILE_RE, mapAdrCorpus } from "../extractors/adrImport.js";
109
+ import { countersignConstraint, countersignDecision } from "../core/countersign.js";
109
110
  import { applyImportedAdrReview, carryImportedAdrReview, importedAdrReviewHash, importedAdrSourceHash, isImportedAdrDecision, pendingImportedAdrReviews } from "../core/importReview.js";
110
111
  import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
111
112
  import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
@@ -362,7 +363,7 @@ program
362
363
  // any mutator. With no commit identity there is no safe public source,
363
364
  // so leave no source-derived records behind for a future pump.
364
365
  store.json.replaceAll("symbols", []);
365
- store.json.replaceAll("edges", []);
366
+ store.json.replaceAll("edges", mergeScannedEdges(store.json.loadAll("edges"), []));
366
367
  store.json.replaceAll("components", []);
367
368
  store.reindex();
368
369
  console.log(" ⚠ skipped code graph: no committed HEAD — commit code, then run `hunch index`");
@@ -496,8 +497,10 @@ program
496
497
  // the same way; do the same for the hook so an upgrade doesn't require
497
498
  // re-running init by hand. Gated on already having post-commit: `index`
498
499
  // is not a setup command (it runs in CI, on any git repo), so it must
499
- // never be what FIRST hooks a repo that never ran init at all.
500
- if (isGitRepo(root) && hookStatus(root).postCommit) {
500
+ // never be what FIRST hooks a repo that never ran init at all. A STALE
501
+ // post-commit block counts too (issue #315): it proves init ran, so the
502
+ // missing post-merge/post-checkout hooks are still this repo's to heal.
503
+ if (isGitRepo(root) && ["installed", "stale"].includes(hookReport(root).postCommit.state)) {
501
504
  const inv = resolveInvocation().shell;
502
505
  installPostMergeHook(root, inv);
503
506
  installPostCheckoutHook(root, inv); // same upgrade path for the workspace-ledger hook
@@ -1516,8 +1519,15 @@ workspacesCmd
1516
1519
  return console.log(" · or set .hunch/config.json {\"workspaces\":{\"publish_public\":true}} to commit it into this repo's .hunch/");
1517
1520
  case "unchanged":
1518
1521
  return console.log(`✓ unchanged since ${out.previous.observed_at} (${out.record.id}) — nothing written`);
1522
+ case "deferred":
1523
+ return console.log(out.reason === "detached-head"
1524
+ ? "deferred: HEAD is detached — the next checkout or ledger read records this machine"
1525
+ : "deferred: a git operation is in progress — the next checkout or ledger read records this machine");
1519
1526
  case "collision":
1520
- return fail(`not written: ${out.reason}\n · \`hunch workspaces forget ${out.record.id}\` removes the stale copy (a normal, revertable memory move), then snapshot again`);
1527
+ // The capture is routed to the overlay, so the only home it can collide with is
1528
+ // this repo's committed `.hunch/`. `forget` refuses that record (an additive pump
1529
+ // never stages a deletion), so the recipe here is the manual git removal.
1530
+ return fail(`not written: ${out.reason}\n the stale copy lives in this repo's .hunch/ — remove it by hand, then snapshot again:\n${publicWorkspaceRemovalRecipe(out.record.id, out.record.machine.label)}`);
1521
1531
  case "written":
1522
1532
  return console.log(`✓ recorded ${out.record.worktrees.length} worktree(s), ${out.record.branches.length} branch(es) as ${out.record.machine.label} (${out.record.id}, publish=${out.record.publish}) → ${out.home === "private" ? "overlay" : "public .hunch/"}${out.flushed ? `, ${out.flushed}` : ""}`);
1523
1533
  }
@@ -1576,7 +1586,7 @@ workspacesCmd
1576
1586
  console.log(` ${r.outcome === "deleted" ? "✓" : r.outcome === "skipped" ? "–" : "✗"} ${r.step.branch}: ${r.detail}`);
1577
1587
  const failed = results.filter((r) => r.outcome === "failed").length;
1578
1588
  const skipped = results.filter((r) => r.outcome === "skipped").length;
1579
- const ledger = recorded === "written" ? " · ledger updated" : recorded === "unchanged" || recorded === "no-home" || recorded === "off" ? "" : ` · ledger not updated (${recorded})`;
1589
+ const ledger = recorded === "written" ? " · ledger updated" : recorded === "unchanged" || recorded === "no-home" || recorded === "off" ? "" : recorded === "deferred" ? " · ledger deferred (git operation in progress or detached HEAD)" : ` · ledger not updated (${recorded})`;
1580
1590
  console.log(`\n${results.length - failed - skipped} deleted, ${skipped ? `${skipped} skipped (nothing changed), ` : ""}${failed} refused by git${ledger}`);
1581
1591
  if (failed || skipped)
1582
1592
  process.exitCode = 1;
@@ -1604,12 +1614,25 @@ workspacesCmd
1604
1614
  const victims = store.recs("workspaces").filter((r) => r.machine.label === machine || r.machine.id === machine || r.id === machine);
1605
1615
  if (!victims.length)
1606
1616
  return fail(`no workspace record for "${machine}" — \`hunch workspaces\` lists the machines in memory`);
1607
- // Decide each record's home BEFORE deleting it, then flush exactly those homes.
1608
- const homes = victims.map((v) => store.getPrivateRec("workspaces", v.id) ? "private" : "public");
1609
- for (const v of victims)
1617
+ // Decide each record's home BEFORE deleting anything, then flush exactly those homes.
1618
+ // A PUBLIC record (workspaces.publish_public) is refused rather than deleted: the
1619
+ // memory pump never stages a tracked deletion, so removing the file here would strand
1620
+ // `D .hunch/workspaces/<id>.json` and wedge every later auto-commit — see
1621
+ // publicWorkspaceRemovalRecipe.
1622
+ const overlay = victims.filter((v) => store.getPrivateRec("workspaces", v.id));
1623
+ const refused = victims.filter((v) => !store.getPrivateRec("workspaces", v.id));
1624
+ for (const v of overlay)
1610
1625
  store.deleteWhereItLives("workspaces", v.id);
1611
- pumpMemoryHomes(store, root, homes, `hunch: forget workspace ${machine}`);
1612
- console.log(`✓ forgot ${victims.length} record(s) for ${machine}`);
1626
+ if (overlay.length) {
1627
+ pumpMemoryHome(store, root, "private", `hunch: forget workspace ${machine}`);
1628
+ console.log(`✓ forgot ${overlay.length} record(s) for ${machine}`);
1629
+ }
1630
+ for (const v of refused) {
1631
+ console.log(`refused: ${v.id} (${v.machine.label}) lives in this repo's .hunch/ — Hunch publication is additive and never stages a deletion, so it is removed by hand:\n`
1632
+ + publicWorkspaceRemovalRecipe(v.id, v.machine.label));
1633
+ }
1634
+ if (refused.length && !overlay.length)
1635
+ process.exitCode = 1;
1613
1636
  }
1614
1637
  finally {
1615
1638
  store.close();
@@ -4501,18 +4524,26 @@ program
4501
4524
  .requiredOption("--by <new>", "decision id that supersedes it")
4502
4525
  .action((oldId, opts) => {
4503
4526
  const { store, root } = storeFor();
4504
- const by = store.json.get("decisions", opts.by);
4527
+ // `old`'s home decides which store the close is written to; `--by` must resolve in
4528
+ // that SAME store, or a private `by` linked against a public `old` would write the
4529
+ // private id straight into the committed public store.
4530
+ const home = decisionMemoryHome(store, oldId);
4531
+ if (!store.decisionInStore(oldId, home === "private")) {
4532
+ store.close();
4533
+ return fail(`decision "${oldId}" not found`);
4534
+ }
4535
+ const by = store.decisionInStore(opts.by, home === "private");
4505
4536
  if (!by) {
4506
4537
  store.close();
4507
- return fail(`--by decision "${opts.by}" not found`);
4538
+ return fail(`--by decision "${opts.by}" not found in the ${home} store that holds "${oldId}"`);
4508
4539
  }
4509
- const closed = store.supersede(oldId, by);
4540
+ const closed = home === "private" ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
4510
4541
  if (!closed) {
4511
4542
  store.close();
4512
- return fail(`decision "${oldId}" not found (or same as --by)`);
4543
+ return fail(`decision "${oldId}" cannot supersede itself`);
4513
4544
  }
4514
4545
  store.reindex();
4515
- pumpMemoryHome(store, root, "public", `hunch: supersede ${oldId} by ${opts.by}`);
4546
+ pumpMemoryHome(store, root, home, `hunch: supersede ${oldId} by ${opts.by}`);
4516
4547
  console.log(`✓ ${oldId} superseded by ${opts.by} — window closed at ${closed.valid_to?.slice(0, 10)}.`);
4517
4548
  store.close();
4518
4549
  });
@@ -4756,10 +4787,12 @@ program
4756
4787
  if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
4757
4788
  // A Codex apply_patch touches every file it lists (and each Move-to
4758
4789
  // destination); the Stop gate must see all of them, not only the first.
4759
- const patchPaths = evt.tool_input?.patch_files?.flatMap((f) => f.moved_to ? [f.path, f.moved_to] : [f.path]);
4760
- const edited = patchPaths?.length ? patchPaths : evt.tool_input?.file_path ? [evt.tool_input.file_path] : [];
4761
- for (const p of edited)
4762
- st = onEdit(st, toRepoRel(root, p));
4790
+ // Same in-repo filter as the pre-edit path: a scratch file outside the
4791
+ // repository is not a product edit, and recording it would block Stop on
4792
+ // a path no check can ever verify.
4793
+ const edited = editTargets(root, evt.tool_input);
4794
+ for (const e of edited)
4795
+ st = onEdit(st, e.target);
4763
4796
  if (edited.length)
4764
4797
  activity = { kind: "edit" };
4765
4798
  }
@@ -5042,7 +5075,10 @@ program
5042
5075
  L.push(` ${r.date} [${r.status}] ${r.title} (${r.id})`);
5043
5076
  }
5044
5077
  if (roadmap.length) {
5045
- L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => r.title).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
5078
+ L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => (r.unconfirmed ? `${r.title} [unconfirmed, ${r.id}]` : r.title)).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
5079
+ const unconfirmed = roadmap.filter((r) => r.unconfirmed).length;
5080
+ if (unconfirmed)
5081
+ L.push(`${unconfirmed} roadmap item(s) are unconfirmed agent testimony — the human confirms each with \`hunch review --confirm <id>${s.unified ? " --private" : ""}\`.`);
5046
5082
  }
5047
5083
  if (pendingReview > 0)
5048
5084
  L.push(`${pendingReview} legacy un-vouched draft(s) — adopt as advisory memory with \`hunch adopt-drafts\` (new captures auto-trust).`);
@@ -5214,7 +5250,11 @@ program
5214
5250
  const reportTaskId = hookReportTaskId(root, provider, evt);
5215
5251
  // A new authoritative prompt gets its own full delivery. An earlier
5216
5252
  // prompt's session-level delta cannot establish this task's receipt.
5217
- if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}`, text) === "delta") {
5253
+ // A subagent reports to the prompt's task but starts with FRESH context:
5254
+ // it never saw that grounding, so its dedup is scoped by its own agent
5255
+ // identity (hashed — the raw agent_id is never retained in the key).
5256
+ const agentKey = evt.agent_id ? `:${reportHash(evt.agent_id).slice(7, 19)}` : "";
5257
+ if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}${agentKey}`, text) === "delta") {
5218
5258
  receipts("refreshed");
5219
5259
  emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${envelope.delivered.filter((item) => item.kind === "decisions").length} decision(s), ${envelope.delivered.filter((item) => item.kind === "constraints").length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
5220
5260
  return;
@@ -5358,6 +5398,8 @@ program
5358
5398
  .description("Answer hash-bound imported-ADR questions, or triage deliberate proposed drafts.")
5359
5399
  .option("--accept <id>", "promote a decision to accepted/human-confirmed (confirms its tripwires)")
5360
5400
  .option("--reject <id>", "reject a draft decision with a durable lifecycle tombstone")
5401
+ .option("--confirm <id>", "countersign an agent-recorded decision (dec_…) or correction (con_…) as human-confirmed WITHOUT changing its status or content — the human act a capture token alone cannot stand in for")
5402
+ .option("--severity <s>", "with --confirm on a correction: the severity you grant (advisory | warning | blocking); default keeps its current severity")
5361
5403
  .option("--approve-import <id>", "approve one exact imported ADR as human-confirmed authority")
5362
5404
  .option("--decline-import <id>", "record that one exact imported ADR was reviewed and must stay advisory")
5363
5405
  .option("--expected-source-hash <hash>", "exact sha256 source hash printed with the imported-ADR question")
@@ -5374,11 +5416,15 @@ program
5374
5416
  store.close();
5375
5417
  return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
5376
5418
  }
5377
- const actionCount = [opts.accept, opts.reject, opts.approveImport, opts.declineImport, opts.acceptVerified, opts.rejectDuplicates].filter(Boolean).length;
5419
+ const actionCount = [opts.accept, opts.reject, opts.confirm, opts.approveImport, opts.declineImport, opts.acceptVerified, opts.rejectDuplicates].filter(Boolean).length;
5378
5420
  if (actionCount > 1) {
5379
5421
  store.close();
5380
5422
  return fail("choose exactly one review action at a time");
5381
5423
  }
5424
+ if (opts.severity && !opts.confirm) {
5425
+ store.close();
5426
+ return fail("--severity applies only with --confirm on a correction");
5427
+ }
5382
5428
  const decisions = () => opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
5383
5429
  let publicGroundingChanged = false;
5384
5430
  const touchedHomes = new Set();
@@ -5418,6 +5464,58 @@ program
5418
5464
  return fail(error instanceof Error ? error.message : String(error));
5419
5465
  }
5420
5466
  }
5467
+ else if (opts.confirm) {
5468
+ // Human countersign of agent testimony. A capture token proves only that the
5469
+ // interview tool was called (inside the agent's channel), so agent-written records
5470
+ // land as agent_recorded; this is the out-of-channel human act that signs them.
5471
+ // Content and status are untouched: confirming a proposed decision is not shipping it.
5472
+ const id = opts.confirm;
5473
+ const SEV = ["advisory", "warning", "blocking"];
5474
+ if (opts.severity && !SEV.includes(opts.severity)) {
5475
+ store.close();
5476
+ return fail(`--severity must be one of: ${SEV.join(", ")}`);
5477
+ }
5478
+ const now = new Date().toISOString();
5479
+ const d = opts.private ? store.getRec("decisions", id) : store.json.get("decisions", id);
5480
+ const c = d ? undefined : opts.private ? store.getRec("constraints", id) : store.json.get("constraints", id);
5481
+ if (!d && !c) {
5482
+ store.close();
5483
+ return fail(`no decision or constraint ${id} found${opts.private ? "" : " in the public home (add --private for overlay records)"}`);
5484
+ }
5485
+ let home;
5486
+ if (d) {
5487
+ if (opts.severity) {
5488
+ store.close();
5489
+ return fail("--severity applies only when confirming a correction (con_…), not a decision");
5490
+ }
5491
+ if (isImportedAdrDecision(d)) {
5492
+ store.close();
5493
+ return fail(`imported ADR ${d.id} requires the hash-bound --approve-import flow shown by hunch review`);
5494
+ }
5495
+ home = opts.private ? decisionMemoryHome(store, d.id) : "public";
5496
+ putDecisionInHome(store, countersignDecision(d, now), home);
5497
+ console.log(`✓ confirmed decision ${id} as human_confirmed (status ${d.status} unchanged)`);
5498
+ }
5499
+ else {
5500
+ home = opts.private && store.getPrivateRec("constraints", id) ? "private" : "public";
5501
+ const next = countersignConstraint(c, now, opts.severity);
5502
+ if (home === "private")
5503
+ store.putPrivate("constraints", next);
5504
+ else
5505
+ store.json.put("constraints", next);
5506
+ const repoWide = next.scope.length === 1 && next.scope[0] === "**";
5507
+ console.log(`✓ confirmed ${next.severity} constraint ${id} as human_confirmed (scope: ${next.scope.join(", ")})`);
5508
+ if (next.severity === "blocking" && repoWide)
5509
+ console.log(" ⚠ repo-wide blocking rule: it can deny any edit at strict firmness and fail hunch check --strict.");
5510
+ }
5511
+ touchedHomes.add(home);
5512
+ store.reindex();
5513
+ if (home === "public") {
5514
+ publicGroundingChanged = true;
5515
+ if (!store.autoCommit)
5516
+ refreshExistingGrounding(root, store);
5517
+ }
5518
+ }
5421
5519
  else if (opts.accept) {
5422
5520
  const d = opts.private ? store.getRec("decisions", opts.accept) : store.json.get("decisions", opts.accept);
5423
5521
  if (!d) {
@@ -5541,7 +5639,7 @@ program
5541
5639
  if (dupCount)
5542
5640
  console.log(`\n Batch-reject the ${dupCount} duplicate(s): hunch review --reject-duplicates`);
5543
5641
  }
5544
- console.log(`\nAccept: hunch review --accept <id> Reject: hunch review --reject <id>`);
5642
+ console.log(`\nAccept: hunch review --accept <id> Reject: hunch review --reject <id> Confirm (sign, keep status): hunch review --confirm <id>`);
5545
5643
  }
5546
5644
  }
5547
5645
  if (touchedHomes.size)
@@ -6741,7 +6839,7 @@ program
6741
6839
  if (!roadmap.length)
6742
6840
  console.log(" (empty — record what's next as a PROPOSED decision via /capture and it appears here)");
6743
6841
  for (const r of roadmap)
6744
- console.log(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
6842
+ console.log(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})${r.unconfirmed ? `\n ${unconfirmedRoadmapMarker(r, { private: !!opts.private })}` : ""}\n ${r.note}`);
6745
6843
  if (pendingReview > 0)
6746
6844
  console.log(`\n (${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` to auto-trust them as advisory)`);
6747
6845
  // Task-record ranking: evaluated automatically on every task write; the kill rule applies itself.
@@ -7018,9 +7116,23 @@ program
7018
7116
  // hook manager's regenerated file — issue #311) is NOT installed.
7019
7117
  const unreachable = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
7020
7118
  .filter(([, e]) => e.state === "unreachable");
7119
+ // Present where git runs it, but its command is not a Hunch launcher that
7120
+ // exists (hand-edited, or an absolute path gone after a reinstall) — issue #315.
7121
+ const stale = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
7122
+ .filter(([, e]) => e.state === "stale");
7123
+ // A bare `hunch init` regenerates post-commit without --private/--commit
7124
+ // and pre-commit without --strict, so pointing at it without naming the
7125
+ // options that keep them silently DOWNGRADES the install. --commit is left
7126
+ // out: init's auto-commit is ON unless --no-auto-commit is passed, so a
7127
+ // bare re-run keeps it and naming it here would claim a loss that never happens.
7128
+ const carried = [...new Set(stale.flatMap(([, e]) => e.flags ?? []))].filter((f) => f !== "--commit");
7129
+ const keep = carried.map((f) => (f === "--strict" ? "--enforce-strict" : f === "--private" ? "--private-sync (or --shared-sync)" : "")).filter(Boolean);
7130
+ const staleKeep = carried.length
7131
+ ? ` — the stale block carried ${carried.join(", ")}${keep.length ? `: pass ${keep.join(", ")} or they are dropped` : ""}`
7132
+ : "";
7021
7133
  // `hunch index` only ever installs post-merge onto an existing
7022
- // post-commit install (it never hooks an un-hooked repo — see
7023
- // isGitRepo(root) && hookStatus(root).postCommit above) and never writes
7134
+ // post-commit install (it never hooks an un-hooked repo — see the
7135
+ // hookReport(root).postCommit.state gate above) and never writes
7024
7136
  // into a manager-owned hook — so the fix hint must not point there when
7025
7137
  // post-commit itself is missing or a hook manager is in play.
7026
7138
  const managedMissing = [report.postCommit, report.postMerge].find((e) => e.state === "missing" && e.manager !== "none");
@@ -7030,6 +7142,7 @@ program
7030
7142
  const problems = [
7031
7143
  missing.length ? `⚠ missing ${missing.join(", ")} — ${fix}` : "",
7032
7144
  unreachable.length ? `⚠ installed but unreachable: ${unreachable.map(([n]) => n).join(", ")} — never runs; run \`hunch init\` to print the snippet for your hook manager` : "",
7145
+ stale.length ? `⚠ stale: ${stale.map(([n]) => n).join(", ")} — the hook's command is not a working Hunch launcher; run \`hunch init\` to regenerate it${staleKeep}` : "",
7033
7146
  ].filter(Boolean);
7034
7147
  console.log(`hooks: ${problems.length
7035
7148
  ? problems.join("\n ")
@@ -7037,6 +7150,13 @@ program
7037
7150
  for (const [name, e] of unreachable) {
7038
7151
  console.log(dim(` ↳ ${name}: ${e.reason ?? "the block sits where git never reaches it"} (${rel(root, e.path)})`));
7039
7152
  }
7153
+ for (const [name, e] of stale) {
7154
+ console.log(dim(` ↳ ${name}: ${e.reason ?? "the block's command is not a Hunch launcher"} (${rel(root, e.path)})`));
7155
+ }
7156
+ // Where the live hooks point — informational (a hook may legitimately run
7157
+ // another install), but the fastest way to spot a stale/foreign launcher.
7158
+ for (const line of hookInvocationLines(report, resolveInvocation().shell))
7159
+ console.log(dim(` ↳ ${line}`));
7040
7160
  // Workspace ledger: what this machine is called, whether its record is in memory,
7041
7161
  // and whether the checkout hook that keeps it fresh is installed.
7042
7162
  try {
@@ -7045,7 +7165,7 @@ program
7045
7165
  const others = store.recs("workspaces").filter((r) => r.machine.id !== machine.id).length;
7046
7166
  const leak = labelLeaksIdentity(machine.label);
7047
7167
  console.log(`workspaces: this machine is ${machine.label}${leak ? ` (⚠ label equals the ${leak})` : ""} · record in memory: ${stored ? `yes (${stored.observed_at})` : "no"} · ${others} other machine(s)` +
7048
- `${hooks.postCheckout ? " · post-checkout hook installed" : report.postCheckout.state === "unreachable" ? " · ⚠ post-checkout hook unreachable" : hooks.postCommit ? " · post-checkout hook not installed (`hunch index` adds it)" : ""}`);
7168
+ `${hooks.postCheckout ? " · post-checkout hook installed" : report.postCheckout.state === "unreachable" ? " · ⚠ post-checkout hook unreachable" : report.postCheckout.state === "stale" ? " · ⚠ post-checkout hook stale" : hooks.postCommit ? " · post-checkout hook not installed (`hunch index` adds it)" : ""}`);
7049
7169
  }
7050
7170
  catch { /* no machine file writable: nothing to report */ }
7051
7171
  }
@@ -7190,28 +7310,17 @@ function readStdin() {
7190
7310
  });
7191
7311
  }
7192
7312
  /** Absolute edit path → repo-relative, forward-slash (constraint scopes are
7193
- * forward-slash globs even on Windows). */
7194
- /** realpath a path even if it doesn't exist yet (a new file an agent is about to
7195
- * Write): resolve the longest existing ancestor, then re-append the missing tail.
7196
- * Idempotent on already-resolved paths. */
7197
- function realpathNorm(p) {
7198
- try {
7199
- return realpathSync.native(p);
7200
- }
7201
- catch {
7202
- const parent = dirname(p);
7203
- if (parent === p)
7204
- return p; // hit the root; nothing more to resolve
7205
- return join(realpathNorm(parent), basename(p));
7206
- }
7207
- }
7208
- /** Repo-relative POSIX path. BOTH ends are realpath-normalized first: on macOS
7209
- * `process.cwd()` (hence findRoot) resolves /var→/private/var, but a hook event's
7210
- * file_path arrives UN-resolved — so a naive relative() yields a bogus "../" path
7211
- * under any symlinked root (/var, /tmp, symlinked $HOME) and the caller treats the
7212
- * file as outside the repo, silently dropping all context (dec_e0a36efbf5). */
7313
+ * forward-slash globs even on Windows), or "" when `abs` isn't (resolvably)
7314
+ * inside `root`. Thin adapter over the shared `repoRelativeTarget` (core/paths.ts,
7315
+ * which realpath-normalizes both ends first — see its docstring for why, incl.
7316
+ * dec_e0a36efbf5): that function passes an unresolvable absolute path through
7317
+ * UNCHANGED (still absolute) rather than signaling failure directly, since other
7318
+ * callers want the original target back to fail their own match safely. This
7319
+ * adapter converts that "still absolute" signal to "" — the sentinel `onEdit`
7320
+ * and `editTargets` below already treat as "skip, not in this repo". */
7213
7321
  function toRepoRel(root, abs) {
7214
- return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
7322
+ const rel = repoRelativeTarget(abs, root);
7323
+ return isAbsolute(rel) || /^[a-zA-Z]:/.test(rel) ? "" : rel;
7215
7324
  }
7216
7325
  /** The in-repo files a pre-edit event would change, each with the lines the edit
7217
7326
  * ADDS to that file. A Codex apply_patch yields one entry per touched path (a
@@ -1,2 +1,16 @@
1
1
  import type { Command } from "commander";
2
+ /** Compact one served partition's ledger. Compaction is read → slice → write, so it takes the
3
+ * partition write lock like every other writer (#286): without it a live server's append between
4
+ * the read and the write is overwritten, regressing head_seq and reusing a seq. The ledger lives
5
+ * in the store's RESOLVED state home (the overlay in shared mode), never `<root>/.hunch` blindly. */
6
+ export declare function compactPartition(root: string, scope: {
7
+ kind: "organization" | "team" | "user" | "repository";
8
+ id: string;
9
+ }, keep: number, lockOptions?: {
10
+ timeoutMs?: number;
11
+ }): Promise<{
12
+ dropped: number;
13
+ floor_seq: number;
14
+ head_seq: number;
15
+ }>;
2
16
  export declare function registerServeCommands(program: Command): void;
package/dist/cli/serve.js CHANGED
@@ -6,9 +6,9 @@ import { initServeConfig, partitionFor, readServeConfig, writeServeConfig } from
6
6
  import { compactLedger } from "../store/changeLedger.js";
7
7
  import { HunchStore } from "../store/hunchStore.js";
8
8
  import { hunchPaths } from "../core/paths.js";
9
- import { partitionOf } from "../store/stateBinding.js";
9
+ import { partitionOf, stateHomeFor } from "../store/stateBinding.js";
10
10
  import { formatReplayReport, verifyReplay } from "../store/replay.js";
11
- import { join } from "node:path";
11
+ import { withWriteLock } from "../serve/writelock.js";
12
12
  import { ScopeSchema, scopePath } from "../core/stateContract.js";
13
13
  import { HUNCH_VERSION } from "../core/version.js";
14
14
  function parseScopeArg(value) {
@@ -18,6 +18,20 @@ function parseScopeArg(value) {
18
18
  throw new Error(`partition must be kind:id (organization|team|user|repository), got "${value}"`);
19
19
  return parsed.data;
20
20
  }
21
+ /** Compact one served partition's ledger. Compaction is read → slice → write, so it takes the
22
+ * partition write lock like every other writer (#286): without it a live server's append between
23
+ * the read and the write is overwritten, regressing head_seq and reusing a seq. The ledger lives
24
+ * in the store's RESOLVED state home (the overlay in shared mode), never `<root>/.hunch` blindly. */
25
+ export async function compactPartition(root, scope, keep, lockOptions = {}) {
26
+ const store = new HunchStore(hunchPaths(root));
27
+ try {
28
+ const { hunchDir } = stateHomeFor(store, scope);
29
+ return await withWriteLock(hunchDir, () => compactLedger(hunchDir, scope, { keep }), lockOptions);
30
+ }
31
+ finally {
32
+ store.close();
33
+ }
34
+ }
21
35
  export function registerServeCommands(program) {
22
36
  const DEFAULT_CONFIG = "hunch-serve.json";
23
37
  const serve = program.command("serve")
@@ -57,7 +71,7 @@ export function registerServeCommands(program) {
57
71
  .requiredOption("--partition <kind:id>", "the partition whose ledger to compact")
58
72
  .option("--keep <n>", "events to keep", "1000")
59
73
  .option("--json", "machine-readable output")
60
- .action((opts) => {
74
+ .action(async (opts) => {
61
75
  const parent = serve.opts();
62
76
  const config = readServeConfig(resolve(parent.config ?? DEFAULT_CONFIG));
63
77
  const scope = parseScopeArg(opts.partition);
@@ -67,7 +81,7 @@ export function registerServeCommands(program) {
67
81
  const keep = Number(opts.keep);
68
82
  if (!Number.isInteger(keep) || keep < 0)
69
83
  throw new Error("--keep must be a non-negative integer");
70
- const result = compactLedger(join(partition.root, ".hunch"), scope, { keep });
84
+ const result = await compactPartition(partition.root, scope, keep);
71
85
  if (opts.json) {
72
86
  console.log(JSON.stringify({ partition: scopePath(scope), ...result }));
73
87
  return;
@@ -306,11 +306,12 @@ export declare function createStateClient(opts: StateClientOptions): {
306
306
  missing: string[];
307
307
  denied: string[];
308
308
  }>;
309
+ /** Liveness; `partitions` is present because this client sends its credential. */
309
310
  health: () => Promise<{
310
311
  ok: boolean;
311
312
  version: string;
312
313
  protocol: string;
313
- partitions: string[];
314
+ partitions?: string[];
314
315
  }>;
315
316
  };
316
317
  export type StateClient = ReturnType<typeof createStateClient>;
@@ -59,6 +59,7 @@ export function createStateClient(opts) {
59
59
  captureBatch: (request) => call("POST", "/nuryel/v1/capture-batch", request),
60
60
  subscribe: (request) => call("POST", "/nuryel/v1/subscribe", request),
61
61
  records: (request) => call("POST", "/nuryel/v1/records", request),
62
+ /** Liveness; `partitions` is present because this client sends its credential. */
62
63
  health: () => call("GET", "/nuryel/v1/health"),
63
64
  };
64
65
  }
@@ -152,8 +152,12 @@ function toolOutput(value) {
152
152
  }
153
153
  function explicitToolOutcome(response) {
154
154
  const raw = obj(response);
155
+ // A bare string carries no status. Codex sends a Bash call's raw output this
156
+ // way, without its exit code, and a failing test run prints output as readily
157
+ // as a passing one. Status-looking text inside it ("Exit code: 0") is output
158
+ // the command itself can print, so it is never parsed as a status either.
155
159
  if (!raw)
156
- return typeof response === "string" && response.trim() ? "success" : "unknown";
160
+ return "unknown";
157
161
  if (raw.success === false || raw.is_error === true || raw.isError === true || (raw.error !== undefined && raw.error !== null))
158
162
  return "failure";
159
163
  const status = raw.status;
@@ -176,8 +180,8 @@ function explicitToolOutcome(response) {
176
180
  if (explicitSuccess)
177
181
  return "success";
178
182
  // Common successful tool-result shapes carry output fields even when the
179
- // output is empty. An unstructured empty string (Codex's native failure
180
- // payload) remains unknown until the host supplies an explicit status.
183
+ // output is empty (Claude Code routes failed calls to PostToolUseFailure
184
+ // instead). An unstructured string remains unknown (see above).
181
185
  if (["stdout", "stderr", "output", "content"].some(key => Object.prototype.hasOwnProperty.call(raw, key)))
182
186
  return "success";
183
187
  return "unknown";
@@ -3,10 +3,18 @@
3
3
  *
4
4
  * hunch_capture_decision issues a short-lived token; the commit path consumes it, so a
5
5
  * decision written through the capture front door is provably the tail of an interview
6
- * — the identity-principle guard against a silent, un-interviewed write. In-memory (the
7
- * MCP server is long-lived); tokens are one-time-use and expire so an abandoned
8
- * interview can't leak. Absence of a token never BLOCKS a write yet (staged
6
+ * PROTOCOL — the identity-principle guard against a silent, un-interviewed write.
7
+ * In-memory (the MCP server is long-lived); tokens are one-time-use and expire so an
8
+ * abandoned interview can't leak. Absence of a token never BLOCKS a write yet (staged
9
9
  * deprecation §9.3) — the caller decides how to treat an un-gated write.
10
+ *
11
+ * WHAT A TOKEN DOES NOT PROVE: that a human answered. Any agent — or content steering
12
+ * one — can call hunch_capture_decision and consume the token it gets back, entirely
13
+ * inside the agent's own MCP channel. So a consumed token never confers
14
+ * `human_confirmed` on its own (the stamp the strict gate and the edit hook trust). It
15
+ * only licenses ASKING the human through a channel the agent does not control: an MCP
16
+ * elicitation answered in the client UI (`isHumanConfirmationAnswer`), or a human
17
+ * running `hunch review --confirm <id>`. Without one of those, the write is testimony.
10
18
  */
11
19
  declare const CAPTURE_TOKEN_TTL_MS: number;
12
20
  /** Issue a token stamped `now` (epoch ms). Prunes expired tokens first so the map can't
@@ -16,4 +24,23 @@ export declare function issueCaptureToken(mint: () => string, now: number): stri
16
24
  /** Consume a token iff it is a live, unexpired capture session. One-time use: a second
17
25
  * consume of the same token returns false. */
18
26
  export declare function consumeCaptureToken(token: string | undefined, now: number): boolean;
27
+ /** The form a human answers in the client UI (MCP `elicitation/create`, form mode). One
28
+ * required boolean, so a bare "accept" (an empty submit) is never read as a yes. */
29
+ export declare const HUMAN_CONFIRMATION_SCHEMA: {
30
+ type: "object";
31
+ properties: {
32
+ confirm: {
33
+ type: "boolean";
34
+ title: string;
35
+ description: string;
36
+ };
37
+ };
38
+ required: string[];
39
+ };
40
+ /** Did the human affirmatively confirm? Only an explicit accept WITH confirm === true
41
+ * counts; decline, cancel, and a missing or false checkbox all mean "no signature". */
42
+ export declare function isHumanConfirmationAnswer(result: {
43
+ action?: string;
44
+ content?: Record<string, unknown>;
45
+ } | null | undefined): boolean;
19
46
  export { CAPTURE_TOKEN_TTL_MS };
@@ -3,10 +3,18 @@
3
3
  *
4
4
  * hunch_capture_decision issues a short-lived token; the commit path consumes it, so a
5
5
  * decision written through the capture front door is provably the tail of an interview
6
- * — the identity-principle guard against a silent, un-interviewed write. In-memory (the
7
- * MCP server is long-lived); tokens are one-time-use and expire so an abandoned
8
- * interview can't leak. Absence of a token never BLOCKS a write yet (staged
6
+ * PROTOCOL — the identity-principle guard against a silent, un-interviewed write.
7
+ * In-memory (the MCP server is long-lived); tokens are one-time-use and expire so an
8
+ * abandoned interview can't leak. Absence of a token never BLOCKS a write yet (staged
9
9
  * deprecation §9.3) — the caller decides how to treat an un-gated write.
10
+ *
11
+ * WHAT A TOKEN DOES NOT PROVE: that a human answered. Any agent — or content steering
12
+ * one — can call hunch_capture_decision and consume the token it gets back, entirely
13
+ * inside the agent's own MCP channel. So a consumed token never confers
14
+ * `human_confirmed` on its own (the stamp the strict gate and the edit hook trust). It
15
+ * only licenses ASKING the human through a channel the agent does not control: an MCP
16
+ * elicitation answered in the client UI (`isHumanConfirmationAnswer`), or a human
17
+ * running `hunch review --confirm <id>`. Without one of those, the write is testimony.
10
18
  */
11
19
  const CAPTURE_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 min
12
20
  const sessions = new Map(); // token -> issuedAt (epoch ms)
@@ -32,5 +40,23 @@ export function consumeCaptureToken(token, now) {
32
40
  sessions.delete(token);
33
41
  return now - at <= CAPTURE_TOKEN_TTL_MS;
34
42
  }
43
+ /** The form a human answers in the client UI (MCP `elicitation/create`, form mode). One
44
+ * required boolean, so a bare "accept" (an empty submit) is never read as a yes. */
45
+ export const HUMAN_CONFIRMATION_SCHEMA = {
46
+ type: "object",
47
+ properties: {
48
+ confirm: {
49
+ type: "boolean",
50
+ title: "I confirm this myself",
51
+ description: "Check only if YOU stated this. Unchecked, it is kept as agent testimony that cannot block.",
52
+ },
53
+ },
54
+ required: ["confirm"],
55
+ };
56
+ /** Did the human affirmatively confirm? Only an explicit accept WITH confirm === true
57
+ * counts; decline, cancel, and a missing or false checkbox all mean "no signature". */
58
+ export function isHumanConfirmationAnswer(result) {
59
+ return result?.action === "accept" && result.content?.confirm === true;
60
+ }
35
61
  export { CAPTURE_TOKEN_TTL_MS };
36
62
  //# sourceMappingURL=capturetoken.js.map