@davesheffer/hunch 1.39.0 → 1.39.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/cli/index.js +193 -65
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/client/state.d.ts +2 -1
  4. package/dist/client/state.js +1 -0
  5. package/dist/core/agenthook.d.ts +14 -0
  6. package/dist/core/agenthook.js +55 -8
  7. package/dist/core/capturetoken.d.ts +30 -3
  8. package/dist/core/capturetoken.js +29 -3
  9. package/dist/core/changeProof.js +5 -1
  10. package/dist/core/checkreport.d.ts +7 -0
  11. package/dist/core/checkreport.js +20 -3
  12. package/dist/core/compare.js +3 -2
  13. package/dist/core/correction.d.ts +10 -4
  14. package/dist/core/correction.js +7 -4
  15. package/dist/core/countersign.d.ts +28 -0
  16. package/dist/core/countersign.js +50 -0
  17. package/dist/core/reviewqueue.js +6 -1
  18. package/dist/core/spawnCommand.js +41 -9
  19. package/dist/core/stateHttp.d.ts +1 -1
  20. package/dist/core/stateHttp.js +3 -1
  21. package/dist/core/taskReportEvidence.js +31 -11
  22. package/dist/core/topics.js +1 -1
  23. package/dist/core/types.d.ts +1 -0
  24. package/dist/core/workspace.d.ts +23 -1
  25. package/dist/core/workspace.js +31 -7
  26. package/dist/extractors/diff.d.ts +34 -0
  27. package/dist/extractors/diff.js +147 -5
  28. package/dist/extractors/git.d.ts +40 -11
  29. package/dist/extractors/git.js +147 -43
  30. package/dist/extractors/workspaces.d.ts +10 -0
  31. package/dist/extractors/workspaces.js +92 -15
  32. package/dist/integrations/gitignore.d.ts +27 -2
  33. package/dist/integrations/gitignore.js +103 -17
  34. package/dist/integrations/hooks.d.ts +61 -7
  35. package/dist/integrations/hooks.js +330 -43
  36. package/dist/integrations/scaffold.js +1 -1
  37. package/dist/integrations/workspaceLedger.d.ts +23 -3
  38. package/dist/integrations/workspaceLedger.js +114 -8
  39. package/dist/mcp/server.js +115 -56
  40. package/dist/serve/app.d.ts +4 -0
  41. package/dist/serve/app.js +56 -36
  42. package/dist/store/hunchStore.d.ts +4 -2
  43. package/dist/store/hunchStore.js +23 -6
  44. package/dist/store/stateBinding.js +111 -42
  45. package/dist/wiki/wiki.d.ts +7 -0
  46. package/dist/wiki/wiki.js +19 -8
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -9,14 +9,14 @@
9
9
  * funnel as every other record: the overlay when one is configured, the public .hunch/
10
10
  * only when `workspaces.publish_public` opts in, nothing otherwise.
11
11
  */
12
- import { execFileSync } from "node:child_process";
12
+ import { execFileSync, spawnSync } from "node:child_process";
13
13
  import { createInterface } from "node:readline";
14
14
  import { foreignRepoEnv, mainWorktreeRoot } from "../extractors/git.js";
15
15
  import { hunchPaths } from "../core/paths.js";
16
16
  import { readConfig, workspacesConfig } from "../core/config.js";
17
17
  import { loadOrCreateMachine } from "../core/machine.js";
18
- import { ago, branchRows, isSafeBranchName, latestPerMachine, isUnverified, planPrune, sameWorkspaceContent, withPublishMode, worktreeRows, } from "../core/workspace.js";
19
- import { snapshotWorkspace } from "../extractors/workspaces.js";
18
+ import { CONTROL_CHARS, ago, branchRows, isSafeBranchName, latestPerMachine, isUnverified, planPrune, sameWorkspaceContent, withPublishMode, worktreeRows, } from "../core/workspace.js";
19
+ import { ignoredPaths, snapshotWorkspace } from "../extractors/workspaces.js";
20
20
  import { flushCapture } from "./sync.js";
21
21
  export function workspaceLedgerView(store, root, opts = {}) {
22
22
  const machine = loadOrCreateMachine();
@@ -95,7 +95,7 @@ export function renderBranchTable(view, rows) {
95
95
  r.machines.join(","),
96
96
  r.worktree_on.length ? r.worktree_on.join(",") + (r.dirty_on.length ? " (dirty)" : "") : "-",
97
97
  describeUpstream(r),
98
- r.merged.status === "merged" ? `yes (${r.merged.method}${r.merged.pr ? `, PR #${r.merged.pr}` : ""})` : r.merged.status === "unmerged" ? "no" : "unknown",
98
+ r.merged.status === "merged" ? `yes (${r.merged.method}${r.merged.pr ? `, PR #${r.merged.pr}` : ""})` : r.merged.status === "unmerged" ? "no" : r.merged.status === "no-commits" ? "no commits" : "unknown",
99
99
  r.action,
100
100
  ]));
101
101
  const deletable = rows.filter((r) => r.action.startsWith("delete local")).length;
@@ -123,15 +123,97 @@ export { branchRows, worktreeRows };
123
123
  // record), with `git worktree remove` (no --force) and `git branch -d` (no -D), so git itself
124
124
  // re-checks "clean" and "merged" as a second line of defense. Nothing here touches a remote
125
125
  // or another machine; their commands are printed for a human to run there.
126
- export function prunePlanFor(view) {
127
- return planPrune(view.live, view.records);
126
+ /** This machine's plan: the pure plan from the live record, then two live checks per step.
127
+ * A step `git branch -d` would refuse is moved to `skipped` BEFORE anything runs (removing
128
+ * the worktree and then failing the branch delete would half-apply the step), and a
129
+ * worktree's ignored files — which `git worktree remove` deletes without asking — are
130
+ * attached so the plan and the confirmation name them. */
131
+ export function prunePlanFor(view, root) {
132
+ const plan = planPrune(view.live, view.records);
133
+ const local = [];
134
+ for (const step of plan.local) {
135
+ const refusal = branchDeleteRefusal(root, step);
136
+ if (refusal) {
137
+ plan.skipped.push({ branch: step.branch, reason: refusal });
138
+ continue;
139
+ }
140
+ if (step.worktree?.path) {
141
+ const ignored = ignoredPaths(step.worktree.path);
142
+ if (ignored === null) {
143
+ plan.skipped.push({ branch: step.branch, reason: "the worktree's ignored files could not be listed" });
144
+ continue;
145
+ }
146
+ if (ignored.total)
147
+ step.ignored = ignored;
148
+ }
149
+ local.push(step);
150
+ }
151
+ plan.local = local;
152
+ return plan;
153
+ }
154
+ function gitEnv() {
155
+ return { ...foreignRepoEnv(process.env), GIT_OPTIONAL_LOCKS: "0" };
156
+ }
157
+ const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
158
+ /** Why `git branch -d -- <branch>` would refuse right now, or null when it would delete.
159
+ * Mirrors git's own rule (builtin/branch.c `branch_merged`): the branch head must be an
160
+ * ancestor of its upstream when one is configured AND resolves, otherwise of HEAD of the
161
+ * worktree the command runs in (the main worktree). Squash and rebase merges never pass it
162
+ * once the upstream is gone or unset, so such a step is reported, not half-applied. The
163
+ * branch name is matched in JS; only refs git printed and SHAs reach git as arguments. */
164
+ export function branchDeleteRefusal(root, step) {
165
+ const main = mainWorktreeRoot(root);
166
+ const env = gitEnv();
167
+ const read = (args) => {
168
+ try {
169
+ return execFileSync("git", args, { cwd: main, env, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "ignore"] }).trim();
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ };
175
+ const unchecked = "git branch -d precondition could not be checked; nothing was changed";
176
+ const refs = read(["for-each-ref", "--format=%(refname)%00%(objectname)%00%(upstream)", "refs/heads/"]);
177
+ if (refs === null)
178
+ return unchecked;
179
+ const entry = refs.split("\n").map((line) => line.split("\0")).find(([ref]) => ref === `refs/heads/${step.branch}`);
180
+ if (!entry)
181
+ return "branch no longer exists";
182
+ const [, head = "", upstream = ""] = entry;
183
+ if (head !== step.head)
184
+ return `branch moved since the snapshot (now ${head.slice(0, 12)})`;
185
+ let reference = null;
186
+ let referenceName = "HEAD";
187
+ if (upstream.startsWith("refs/")) {
188
+ const resolved = read(["rev-parse", "--verify", "-q", "--end-of-options", `${upstream}^{commit}`]);
189
+ if (resolved && SHA.test(resolved)) {
190
+ reference = resolved;
191
+ referenceName = upstream.replace(/^refs\/(?:remotes|heads)\//, "");
192
+ }
193
+ }
194
+ if (!reference) {
195
+ const resolved = read(["rev-parse", "--verify", "-q", "--end-of-options", "HEAD^{commit}"]);
196
+ if (!resolved || !SHA.test(resolved))
197
+ return "git branch -d would refuse: the main worktree's HEAD does not resolve; delete manually after checking";
198
+ reference = resolved;
199
+ }
200
+ const r = spawnSync("git", ["merge-base", "--is-ancestor", step.head, reference], { cwd: main, env, timeout: 30_000, stdio: "ignore" });
201
+ if (r.error || r.status === null || (r.status !== 0 && r.status !== 1))
202
+ return unchecked;
203
+ if (r.status === 0)
204
+ return null;
205
+ return step.method === "squash" || step.method === "rebase"
206
+ ? `${step.method}-merged: git branch -d would refuse (not merged into ${referenceName}); delete manually after checking`
207
+ : `git branch -d would refuse: not merged into ${referenceName}; update it, or delete manually after checking`;
128
208
  }
129
209
  /** Execute the local steps of a plan. Each command is a fixed argv; the branch name was
130
210
  * validated by the record schema and is passed after `--`; the worktree path comes from
131
- * `git worktree list` on this machine. A failure stops that step, never the others. */
211
+ * `git worktree list` on this machine. `git branch -d`'s own precondition is re-checked
212
+ * BEFORE the worktree is removed, so a step either runs whole or not at all. A failure
213
+ * stops that step, never the others. */
132
214
  export function applyPrune(root, steps) {
133
215
  const main = mainWorktreeRoot(root);
134
- const env = foreignRepoEnv(process.env);
216
+ const env = gitEnv();
135
217
  const git = (args) => execFileSync("git", args, { cwd: main, env, encoding: "utf8", timeout: 60_000, stdio: ["ignore", "pipe", "pipe"] }).trim();
136
218
  const results = [];
137
219
  for (const step of steps) {
@@ -139,6 +221,11 @@ export function applyPrune(root, steps) {
139
221
  results.push({ step, outcome: "failed", detail: "refused: unsafe branch name" });
140
222
  continue;
141
223
  }
224
+ const refusal = branchDeleteRefusal(root, step);
225
+ if (refusal) {
226
+ results.push({ step, outcome: "skipped", detail: `skipped (worktree and branch kept): ${refusal}` });
227
+ continue;
228
+ }
142
229
  try {
143
230
  const detail = [];
144
231
  if (step.worktree?.path) {
@@ -160,6 +247,23 @@ export function applyPrune(root, steps) {
160
247
  }
161
248
  return results;
162
249
  }
250
+ /** A file name is repository content a terminal would interpret: control characters are
251
+ * shown escaped, never emitted. */
252
+ function printable(text) {
253
+ return text.replace(new RegExp(CONTROL_CHARS.source, "g"), (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
254
+ }
255
+ export function describeIgnored(ignored) {
256
+ const more = ignored.total - ignored.shown.length;
257
+ return `${ignored.total} ignored path(s) in the worktree: ${ignored.shown.map(printable).join(", ")}${more > 0 ? `, … and ${more} more` : ""}`;
258
+ }
259
+ /** The confirmation question for `prune --apply`: counts, and — because `git worktree
260
+ * remove` deletes ignored files (.env, build output) without asking — every worktree's
261
+ * ignored paths by name. */
262
+ export function pruneConfirmQuestion(view, plan) {
263
+ const worktrees = plan.local.filter((s) => s.worktree).length;
264
+ const details = plan.local.filter((s) => s.ignored).map((s) => ` ${s.branch}: removing its worktree also deletes ${describeIgnored(s.ignored)}`);
265
+ return [...details, `Delete ${plan.local.length} branch(es)${worktrees ? ` and remove ${worktrees} worktree(s)` : ""} on ${view.machine.label}?`].join("\n");
266
+ }
163
267
  /** Interactive yes/no; false when stdin is not a terminal (the caller then needs --yes). */
164
268
  export async function confirmPrune(question) {
165
269
  if (!process.stdin.isTTY)
@@ -182,6 +286,8 @@ export function renderPrunePlan(view, plan) {
182
286
  L.push(` ${step.branch} — ${step.why}`);
183
287
  for (const c of step.commands)
184
288
  L.push(` ${c}`);
289
+ if (step.ignored)
290
+ L.push(` ⚠ also deletes ${describeIgnored(step.ignored)}`);
185
291
  }
186
292
  if (plan.skipped.length) {
187
293
  L.push("", "Merged but left alone on this machine:");
@@ -24,11 +24,12 @@ import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultS
24
24
  import { selectEmbedder } from "../store/embedder.js";
25
25
  import { decisionId, findingId } from "../core/ids.js";
26
26
  import { buildCorrectionConstraint } from "../core/correction.js";
27
+ import { confirmCommand } from "../core/countersign.js";
27
28
  import { knownRepoDeps } from "../synthesis/tripwires.js";
28
29
  import { refreshExistingGrounding } from "../integrations/providers.js";
29
30
  import { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
30
31
  import { workspacesConfig } from "../core/config.js";
31
- import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunchStatus, sameRemoteUrl, currentBranch } from "../extractors/git.js";
32
+ import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatus, sameRemoteUrl, currentBranch } from "../extractors/git.js";
32
33
  import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
33
34
  import { withWriteLock } from "../serve/writelock.js";
34
35
  import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
@@ -69,7 +70,7 @@ import { readActivePendingRepairs, withheldRewrites } from "../core/repairqueue.
69
70
  import { scanRecord, publicationWarning, loadVocabulary } from "../core/publication.js";
70
71
  import { premiseEscalations } from "../core/premises.js";
71
72
  import { applyImportedAdrReview, pendingImportedAdrReviews } from "../core/importReview.js";
72
- import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
73
+ import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken, HUMAN_CONFIRMATION_SCHEMA, isHumanConfirmationAnswer } from "../core/capturetoken.js";
73
74
  import { randomUUID } from "node:crypto";
74
75
  import { existsSync } from "node:fs";
75
76
  import { join } from "node:path";
@@ -393,6 +394,23 @@ function deliveredContext(root, target, envelope, sessionId) {
393
394
  // thin wrappers bind the process clock and id source at the call site (§5 Stage 1).
394
395
  const issueCaptureToken = () => issueToken(randomUUID, Date.now());
395
396
  const consumeCaptureToken = (token) => consumeToken(token, Date.now());
397
+ async function askHumanToConfirm(server, message) {
398
+ if (!server.server.getClientCapabilities()?.elicitation?.form)
399
+ return "unavailable";
400
+ try {
401
+ const answer = await server.server.elicitInput({ mode: "form", message, requestedSchema: HUMAN_CONFIRMATION_SCHEMA });
402
+ return isHumanConfirmationAnswer(answer) ? "confirmed" : "declined";
403
+ }
404
+ catch {
405
+ return "unavailable";
406
+ }
407
+ }
408
+ function unconfirmedReason(c) {
409
+ return c === "declined"
410
+ ? "the human did not confirm it in the client prompt"
411
+ : "this client could not ask the human to confirm it (no MCP form elicitation, or the request failed)";
412
+ }
413
+ const clipForPrompt = (s, max = 400) => (s.length > max ? `${s.slice(0, max - 1)}…` : s);
396
414
  /** The interrogation protocol returned by hunch_capture_decision. With `deciding`,
397
415
  * the choice is NOT yet made: the verdict loop runs first so the record's
398
416
  * alternatives_rejected are attacks that actually ran — not post-hoc fiction. */
@@ -416,7 +434,7 @@ function grillingProtocol(topic, token, deciding = false) {
416
434
  "1. Grill ONE focused question at a time. Push back on hand-wavy answers. Resolve every branch of the decision tree before committing — an unexamined decision poisons the graph.",
417
435
  `2. Confirm the TOPIC anchor with the human before committing${topic ? ` (proposed: "${topic}")` : ""}. Exactly one topic per decision; if it spans two, split into two captures.`,
418
436
  "3. Capture REJECTED alternatives explicitly — for each, what it was and why not. This is what makes the decision enforceable (Veto/drift check against it).",
419
- `4. Commit with hunch_record_decision, passing capture_token:"${token}" and the confirmed topic. The artifact is the graph write, not prose.`,
437
+ `4. Commit with hunch_record_decision, passing capture_token:"${token}" and the confirmed topic. The artifact is the graph write, not prose. The token is not a human signature: Hunch asks the human to confirm in the client when the client supports it; otherwise the record stays agent testimony until the human runs the \`hunch review --confirm <id>\` command the response prints.`,
420
438
  "5. On CONFLICT with an existing live decision for the topic, do NOT auto-supersede — Hunch refuses and presents both; let the human choose to supersede (link), split the topic, or discard.",
421
439
  "",
422
440
  "Required before commit: topic, title, decision, context (the rationale/why), alternatives_rejected. Missing any → keep grilling.",
@@ -1426,7 +1444,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1426
1444
  // -- hunch_capture_decision (decision-grounding: the grilling front door) --
1427
1445
  server.registerTool("hunch_capture_decision", {
1428
1446
  title: "Capture a decision (grilling interview)",
1429
- description: "Start a decision-capture interview: returns the grilling protocol (interrogate ONE question at a time until the decision tree is resolved) plus a capture-session token. Grill the human, then commit via hunch_record_decision with the token + confirmed topic. Use for '/capture', 'record this decision', 'grill me on this'. The token proves the write is the tail of an interview, not a silent guess. Returns the protocol text and the token; it writes nothing. Not for corrections (hunch_record_correction) or observations (hunch_record_finding).",
1447
+ description: "Start a decision-capture interview: returns the grilling protocol (interrogate ONE question at a time until the decision tree is resolved) plus a capture-session token. Grill the human, then commit via hunch_record_decision with the token + confirmed topic. Use for '/capture', 'record this decision', 'grill me on this'. The token proves the write is the tail of an interview, not a silent guess — it is NOT a human signature: human-confirmed authority needs the human's own confirmation (a client prompt, or `hunch review --confirm <id>`). Returns the protocol text and the token; it writes nothing. Not for corrections (hunch_record_correction) or observations (hunch_record_finding).",
1430
1448
  inputSchema: {
1431
1449
  topic: z.string().optional().describe("proposed topic anchor (confirm with the human before committing)"),
1432
1450
  seed: z.string().optional().describe("what the decision is about, to focus the first question"),
@@ -1510,7 +1528,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1510
1528
  supersedes: z.string().optional().describe("id of a decision this one replaces — closes its valid-time window (invalidate, don't delete)"),
1511
1529
  private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — for sensitive decisions kept out of a public repo. Errors if no private store is configured."),
1512
1530
  }),
1513
- capture_token: z.string().optional().describe("token from hunch_capture_decision — proves this write is the tail of a grilling interview. Omit only for a quick manual record (a deprecation nudge is returned)."),
1531
+ capture_token: z.string().optional().describe("token from hunch_capture_decision — proves this write is the tail of a grilling interview (not a human signature: Hunch asks the human to confirm in the client when supported). Omit only for a quick manual record (a deprecation nudge is returned)."),
1514
1532
  task_id: TaskIdSchema.optional().describe("Exact task ID for observing this successful save; reporting never changes capture authority."),
1515
1533
  cwd: cwdHintField,
1516
1534
  },
@@ -1546,32 +1564,36 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1546
1564
  // from the commit) stay upgradeable by a different identity. Same-identity
1547
1565
  // re-record remains the countersign/refine path for every tier.
1548
1566
  const curated = ["human_confirmed", "agent_recorded"].some((t) => existing?.provenance.source.split("+").includes(t));
1549
- // AUTHORSHIP STAMP (memory supply chain): only a consumed capture token — proof a
1550
- // grilling interview preceded this write — mints human_confirmed. Any agent can
1551
- // CALL this tool mid-session, possibly steered by untrusted content it read;
1552
- // "the human probably asked me to" is testimony, not a signature.
1567
+ // AUTHORSHIP STAMP (memory supply chain): human_confirmed needs a HUMAN act. Any
1568
+ // agent can CALL this tool mid-session, possibly steered by untrusted content it
1569
+ // read; "the human probably asked me to" is testimony, not a signature. A consumed
1570
+ // capture token proves only that hunch_capture_decision was called — also inside
1571
+ // the agent's channel — so it licenses ASKING the human (client elicitation, below)
1572
+ // and nothing more.
1553
1573
  //
1554
- // Resolved HERE, before the overwrite guard, because the guard's answer depends on
1555
- // it: testimony must yield to a signature. (Consuming before a possible refusal
1556
- // burns the token, which is the safe direction — a re-run of /capture mints another.)
1574
+ // Consumed HERE, before the overwrite guard, because the guard's answer depends on
1575
+ // it. (Consuming before a possible refusal burns the token, which is the safe
1576
+ // direction — a re-run of /capture mints another.)
1557
1577
  const gated = consumeCaptureToken(capture_token);
1558
1578
  const existingTiers = existing?.provenance.source.split("+") ?? [];
1559
1579
  const existingIsHuman = existingTiers.includes("human_confirmed");
1560
1580
  // A slot held only by AGENT TESTIMONY must not block a later human capture — the
1561
1581
  // stamp's own contract says so ("never lock the id slot against a later human
1562
- // capture"), but including agent_recorded in `curated` did exactly that. A
1563
- // human_confirmed slot stays protected as before (issue #23): a signature is never
1564
- // displaced by a differently-identified record, vouched or not.
1565
- const conflictsWithHuman = curated && !sameHumanIdentity && !(gated && !existingIsHuman);
1566
- if (conflictsWithHuman) {
1567
- return refused(`Decision id ${id} already identifies a different curated decision: ` +
1568
- `"${existing.title}"${existing.topic ? ` (topic "${existing.topic}")` : ""}. ` +
1569
- `Refusing to overwrite it with "${decision.title}"${decision.topic ? ` (topic "${decision.topic}")` : ""}. ` +
1570
- "Record the additional decision without commit, or reuse the incumbent topic/title when refining the same decision.");
1571
- }
1582
+ // capture"). A human_confirmed slot stays protected as before (issue #23): a
1583
+ // signature is never displaced by a differently-identified record, vouched or not.
1584
+ // Testimony yields only to a HUMAN-CONFIRMED write, so an un-tokened write is
1585
+ // refused now and a tokened one is refused below unless the human confirms.
1586
+ const slotConflict = curated && !sameHumanIdentity;
1587
+ const slotRefusal = () => refused(`Decision id ${id} already identifies a different curated decision: ` +
1588
+ `"${existing.title}"${existing.topic ? ` (topic "${existing.topic}")` : ""}. ` +
1589
+ `Refusing to overwrite it with "${decision.title}"${decision.topic ? ` (topic "${decision.topic}")` : ""}. ` +
1590
+ "Record the additional decision without commit, or reuse the incumbent topic/title when refining the same decision.");
1591
+ if (slotConflict && (existingIsHuman || !gated))
1592
+ return slotRefusal();
1572
1593
  // Un-token'd writes land as agent_recorded: fully functional advisory memory that
1573
1594
  // never carries human authority (strict/veto gates key on human_confirmed) and
1574
- // surfaces with a testimony marker. Re-record through /capture to countersign.
1595
+ // surfaces with a testimony marker. A human countersigns it (client prompt during
1596
+ // /capture, or `hunch review --confirm <id>`).
1575
1597
  //
1576
1598
  // A signature already on this slot is INHERITED, never erased. The un-token'd path
1577
1599
  // is exactly what the nudge below tells an agent to do ("re-record… supersedes"),
@@ -1580,10 +1602,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1580
1602
  // human had vouched for. That inverts the whole point of the stamp: it exists to
1581
1603
  // stop an agent CLAIMING human authority, not to let one DESTROY it. Downgrading a
1582
1604
  // signature is a human act (`hunch review --reject`, or supersede via /capture).
1583
- const tier = gated || existingIsHuman ? "human_confirmed" : "agent_recorded";
1584
- const source = existing && existing.provenance.source.includes("llm_draft")
1585
- ? `llm_draft+${tier}`
1586
- : tier;
1605
+ // The tier is resolved after the refusal guards below (it may need the human's
1606
+ // answer), so `rec` carries a placeholder provenance until then.
1587
1607
  const now = new Date().toISOString();
1588
1608
  const rec = {
1589
1609
  id,
@@ -1612,7 +1632,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1612
1632
  valid_from: existing?.valid_from ?? now,
1613
1633
  valid_to: existing?.valid_to ?? null,
1614
1634
  retired: existing?.retired ?? { symbols: [], deps: [] },
1615
- provenance: { source, confidence: gated ? 0.95 : 0.75, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
1635
+ provenance: { source: "agent_recorded", confidence: 0.75, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
1616
1636
  date: now,
1617
1637
  };
1618
1638
  // Where this write will actually land (see captureHome). Resolved BEFORE the
@@ -1641,6 +1661,31 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1641
1661
  `re-record with supersedes:<id> to replace it (linked, same store), pick a distinct topic to split, or discard this capture.`);
1642
1662
  }
1643
1663
  }
1664
+ // Human confirmation: asked only for a tokened write (the /capture tail — never
1665
+ // prompt-spam every agent write), only after every refusal guard has passed (never
1666
+ // ask a human to confirm a write that is then refused), and only through the client
1667
+ // UI, a channel the agent does not control.
1668
+ const confirmation = gated
1669
+ ? await askHumanToConfirm(server, [
1670
+ "Hunch: an agent is recording this engineering decision on your behalf. Confirm only if YOU made this decision.",
1671
+ "",
1672
+ `Title: ${clipForPrompt(rec.title, 200)}`,
1673
+ ...(rec.topic ? [`Topic: ${rec.topic}`] : []),
1674
+ `Status: ${rec.status}`,
1675
+ `Decision: ${clipForPrompt(rec.decision || "(none)")}`,
1676
+ ...(rec.alternatives_rejected.length ? [`Rejected: ${clipForPrompt(rec.alternatives_rejected.join("; "))}`] : []),
1677
+ "",
1678
+ "Confirmed, it carries your authority (human_confirmed). Unconfirmed, it is kept as agent testimony.",
1679
+ ].join("\n"))
1680
+ : null;
1681
+ const humanSigned = confirmation === "confirmed";
1682
+ if (slotConflict && !humanSigned)
1683
+ return slotRefusal();
1684
+ const tier = humanSigned || existingIsHuman ? "human_confirmed" : "agent_recorded";
1685
+ const source = existing && existing.provenance.source.includes("llm_draft")
1686
+ ? `llm_draft+${tier}`
1687
+ : tier;
1688
+ rec.provenance = { ...rec.provenance, source, confidence: humanSigned ? 0.95 : 0.75 };
1644
1689
  // Route the write to its ONE home: an explicit private:true goes to the overlay
1645
1690
  // (putPrivate throws rather than silently falling public); in unified ("shared")
1646
1691
  // mode EVERY capture goes to the overlay; else the public store.
@@ -1668,16 +1713,19 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1668
1713
  const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined, observed.observe);
1669
1714
  const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate, rec, hunchPaths(root).hunch) + observed.note;
1670
1715
  // Capture-session gate (staged deprecation, §9.3): the token was consumed
1671
- // above (it also decides the provenance tier). No token still writes
1672
- // (non-breaking) but lands as agent_recorded with a nudge toward /capture.
1673
- // A token presented but unknown to THIS process (server restart/expiry) is
1674
- // not shamed — but it also cannot be VERIFIED, so the record still lands
1675
- // agent_recorded with a note saying how to countersign.
1676
- const captureNote = gated
1677
- ? " [via capture front door]"
1678
- : capture_token
1679
- ? `\n\nℹ The capture token could not be verified (server restart or expiry), so this record is stamped agent_recorded. Re-record through hunch_capture_decision → hunch_record_decision to countersign it as human_confirmed.`
1680
- : `\n\n⚠ Recorded WITHOUT a capture interview — the record stands as agent_recorded TESTIMONY (advisory: it never carries human authority; a /capture interview on the same topic/title countersigns it). Harden it NOW in one exchange instead of switching flows: answer the first grilling question directly — "What alternative did you seriously consider and reject for '${rec.title.slice(0, 60)}', and what breaks if a future session re-introduces it?" — then fold the answer into alternatives_rejected via a /capture interview (hunch_capture_decision → hunch_record_decision(supersedes: ${id})), which countersigns the record as human_confirmed. (A future major version will require a capture token here.)`;
1716
+ // above. No token still writes (non-breaking) but lands as agent_recorded with a
1717
+ // nudge toward /capture. A token — verified or not — is never a signature: only
1718
+ // the human's confirmation (client prompt or `hunch review --confirm`) is.
1719
+ const confirmCmd = confirmCommand(id, { private: home === "private" });
1720
+ const captureNote = humanSigned
1721
+ ? " [via capture front door — confirmed by the human in the client]"
1722
+ : existingIsHuman
1723
+ ? " [the existing human signature on this record is retained]"
1724
+ : gated
1725
+ ? `\n\nℹ Interview recorded, but a capture token is not a human signature and ${unconfirmedReason(confirmation)}, so this record stands as agent_recorded TESTIMONY (advisory; it never carries human authority). The human confirms it by running: ${confirmCmd}`
1726
+ : capture_token
1727
+ ? `\n\nℹ The capture token could not be verified (server restart or expiry), so this record is stamped agent_recorded. The human confirms it by running: ${confirmCmd}`
1728
+ : `\n\n⚠ Recorded WITHOUT a capture interview — the record stands as agent_recorded TESTIMONY (advisory: it never carries human authority). Harden it NOW in one exchange instead of switching flows: answer the first grilling question directly — "What alternative did you seriously consider and reject for '${rec.title.slice(0, 60)}', and what breaks if a future session re-introduces it?" — then fold the answer into alternatives_rejected via a /capture interview (hunch_capture_decision → hunch_record_decision(supersedes: ${id})). Human authority needs the human's own confirmation: the client prompt during /capture, or \`${confirmCmd}\`. (A future major version will require a capture token here.)`;
1681
1729
  // Quality nudge only when the untokened deprecation nudge isn't already
1682
1730
  // grilling — one advisory voice per response, never two.
1683
1731
  const quality = gated || capture_token ? qualityNudge(rec) : "";
@@ -1712,7 +1760,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1712
1760
  rationale: z.string().optional().describe("Why it must hold."),
1713
1761
  source_decision: z.string().optional().describe("id of a decision this correction derives from."),
1714
1762
  private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — a sensitive rule enforced locally (pre-edit hook + local check) but never exposed in a public PR comment. Errors if no private store is configured."),
1715
- capture_token: z.string().optional().describe("token from hunch_capture_decision. The rule is recorded and enforced either way — the token only decides whether it may DENY: without one it lands as advisory testimony capped at severity 'warning'."),
1763
+ capture_token: z.string().optional().describe("token from hunch_capture_decision. The rule is recorded and enforced either way, as agent testimony capped at severity 'warning'. A token never lets it DENY: blocking authority comes only from a human running the printed `hunch review --confirm <id> --severity <s>` command."),
1716
1764
  task_id: TaskIdSchema.optional().describe("Exact task ID for observing this successful save; reporting never changes capture authority."),
1717
1765
  cwd: cwdHintField,
1718
1766
  },
@@ -1724,19 +1772,28 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1724
1772
  // paths (edit-tool payloads and MCP roots are absolute) and every consumer matches
1725
1773
  // repo-relative — without this the rule would be blocking-but-inert and would leak
1726
1774
  // the local filesystem path into the committed graph.
1727
- // Same authorship tier as hunch_record_decision: a consumed token mints the
1728
- // signature, an un-token'd write is testimony. Here the stakes are HIGHER — a
1729
- // blocking constraint DENIES edits, so an un-vouched write is capped at
1730
- // "warning" rather than being refused. Never Twice still lands immediately.
1731
- const vouched = consumeCaptureToken(input.capture_token);
1732
- const rec = buildCorrectionConstraint({ ...input, knownDeps: knownRepoDeps(root), root, vouched }, new Date().toISOString());
1775
+ // AUTHORSHIP TIER. A correction recorded through MCP is ALWAYS agent testimony: a
1776
+ // capture token (callable and consumable by any agent) proves only that the
1777
+ // interview tool was called, and an in-client confirmation is not used here either.
1778
+ // The stakes are higher than for a decision — a blocking constraint DENIES edits
1779
+ // (the edit hook keys on severity alone) and fails strict checks — so blocking
1780
+ // authority comes only from a human running `hunch review --confirm <id> --severity <s>`
1781
+ // outside the agent channel. The write is capped below blocking rather than refused:
1782
+ // Never Twice still lands immediately and is surfaced at edit time and in CI.
1783
+ const gated = consumeCaptureToken(input.capture_token);
1784
+ const now = new Date().toISOString();
1785
+ const knownDeps = knownRepoDeps(root);
1786
+ // What a human confirmation would grant: the severity the caller requested, after
1787
+ // the repo-wide scope guard.
1788
+ const requested = buildCorrectionConstraint({ ...input, knownDeps, root, vouched: true }, now);
1733
1789
  // Private corrections go to the overlay (enforced locally via the merged read,
1734
1790
  // never rendered into the public CI comment, which is public-only by construction).
1735
1791
  const home = store.captureHome(!!input.private);
1736
- if (home === "public" && rec.source_decision && !store.json.get("decisions", rec.source_decision)) {
1737
- const location = store.getPrivateRec("decisions", rec.source_decision) ? "exists only in the private overlay" : "does not exist in the public home";
1738
- return refused(`source decision ${rec.source_decision} ${location}; refusing to record public correction ${rec.id}.`);
1792
+ if (home === "public" && requested.source_decision && !store.json.get("decisions", requested.source_decision)) {
1793
+ const location = store.getPrivateRec("decisions", requested.source_decision) ? "exists only in the private overlay" : "does not exist in the public home";
1794
+ return refused(`source decision ${requested.source_decision} ${location}; refusing to record public correction ${requested.id}.`);
1739
1795
  }
1796
+ const rec = buildCorrectionConstraint({ ...input, knownDeps, root, vouched: false }, now);
1740
1797
  const existing = home === "private" ? store.getPrivateRec("constraints", rec.id) : store.json.get("constraints", rec.id);
1741
1798
  // Same cross-home twin guard as the decision path above.
1742
1799
  const stored = store.putCapture("constraints", rec, !!input.private);
@@ -1765,11 +1822,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1765
1822
  const reviewNote = "\n\nREVIEW PENDING: After the fix is committed, run hunch index; an installed post-commit hook retries this automatically on the fixing commit. Only the supported static ESM import-declaration package projection is eligible, and it remains activation-blocked; the immediate guard is already durable.";
1766
1823
  // Say plainly which tier this landed in. A silent downgrade would be its own
1767
1824
  // dishonesty: the caller asked for "blocking" and must be told it is not.
1768
- const tierNote = vouched
1769
- ? ""
1770
- : `
1825
+ const capped = requested.severity !== rec.severity;
1826
+ const why = gated
1827
+ ? "Recorded after a capture interview, but a capture token is not a human signature"
1828
+ : "Recorded WITHOUT a capture interview or a human confirmation";
1829
+ const tierNote = `
1771
1830
 
1772
- ⚠ Recorded WITHOUT a capture interview — this rule is agent_recorded TESTIMONY${input.severity === "blocking" ? ' and was capped from "blocking" to "warning"' : ""}. It IS enforced: the pre-edit hook and CI surface it on every matching edit from now on. What it cannot do is DENY an edit — only a rule a human countersigned may block. Countersign it by re-recording through hunch_capture_decision → hunch_record_correction(capture_token).`;
1831
+ ⚠ ${why} — this rule is agent_recorded TESTIMONY${capped ? ` and was capped from "${requested.severity}" to "${rec.severity}"` : ""}. It IS enforced: the pre-edit hook and CI surface it on every matching edit from now on. What it cannot do is DENY an edit — only a rule a human confirmed outside the agent channel may block. The human confirms it by running: ${confirmCommand(rec.id, { private: home === "private", severity: requested.severity })}`;
1773
1832
  const dest = destinationNote(resolveDestRoot(home, store, root));
1774
1833
  return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where}${dest} It now ${enforce}.${reviewNote}${tierNote}`);
1775
1834
  }
@@ -2138,8 +2197,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2138
2197
  const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
2139
2198
  if (!files.length)
2140
2199
  return ok(`VERDICT: ✅ PASS — no changed files in ${scope}.`);
2141
- const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : working ? workingDiff(root) : stagedDiff(root);
2142
- const report = store.buildCheckReport(files, diff, { strict: true, lastChange: (f) => lastChangeDate(f, root) });
2200
+ const gate = commit ? commitGateDiff(commit, root) : base ? rangeGateDiff(base, root) : working ? workingGateDiff(root) : stagedGateDiff(root);
2201
+ const report = store.buildCheckReport(files, gate.diff, { strict: true, lastChange: (f) => lastChangeDate(f, root), diffStatus: gate });
2143
2202
  const v = verdict(report);
2144
2203
  const head = v === "block"
2145
2204
  ? "VERDICT: ⛔ BLOCK — a recorded guard requires review; inspect the cited scope and evidence below before merge."
@@ -2181,8 +2240,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2181
2240
  const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
2182
2241
  if (!files.length)
2183
2242
  return ok(`No changed files in ${scope}.`);
2184
- const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : working ? workingDiff(root) : stagedDiff(root);
2185
- return ok(renderImpact(store.prImpact(files, diff), scope));
2243
+ const gate = commit ? commitGateDiff(commit, root) : base ? rangeGateDiff(base, root) : working ? workingGateDiff(root) : stagedGateDiff(root);
2244
+ return ok(renderImpact(store.prImpact(files, gate.diff, gate), scope));
2186
2245
  }
2187
2246
  catch (e) {
2188
2247
  return err(`Failed to compute impact: ${e.message}`);
@@ -34,6 +34,10 @@ export interface ServeOptions {
34
34
  version?: string;
35
35
  /** Injectable for tests: how a partition's store is opened. */
36
36
  openStore?: (root: string) => HunchStore;
37
+ /** Server-side log line sink for 5xx specifics; defaults to stderr. */
38
+ log?: (line: string) => void;
39
+ /** Injectable for tests: how long a write waits for its partition's lock. */
40
+ writeLockTimeoutMs?: number;
37
41
  }
38
42
  export declare function createServeApp(config: ServeConfig, opts?: ServeOptions): Server & {
39
43
  closeStores: () => void;