@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
@@ -12,7 +12,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
13
  import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
14
14
  import { z } from "zod";
15
- import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
15
+ import { hunchPaths, findRoot, toPosixTarget, repoRelativeTarget } from "../core/paths.js";
16
+ import { isIndexedPath, matchSymbolsTiered } from "../core/glob.js";
16
17
  import { resolveMcpToolset } from "./toolset.js";
17
18
  import { readConfig } from "../core/config.js";
18
19
  import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
@@ -24,6 +25,7 @@ import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultS
24
25
  import { selectEmbedder } from "../store/embedder.js";
25
26
  import { decisionId, findingId } from "../core/ids.js";
26
27
  import { buildCorrectionConstraint } from "../core/correction.js";
28
+ import { confirmCommand } from "../core/countersign.js";
27
29
  import { knownRepoDeps } from "../synthesis/tripwires.js";
28
30
  import { refreshExistingGrounding } from "../integrations/providers.js";
29
31
  import { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
@@ -69,7 +71,7 @@ import { readActivePendingRepairs, withheldRewrites } from "../core/repairqueue.
69
71
  import { scanRecord, publicationWarning, loadVocabulary } from "../core/publication.js";
70
72
  import { premiseEscalations } from "../core/premises.js";
71
73
  import { applyImportedAdrReview, pendingImportedAdrReviews } from "../core/importReview.js";
72
- import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
74
+ import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken, HUMAN_CONFIRMATION_SCHEMA, isHumanConfirmationAnswer } from "../core/capturetoken.js";
73
75
  import { randomUUID } from "node:crypto";
74
76
  import { existsSync } from "node:fs";
75
77
  import { join } from "node:path";
@@ -393,6 +395,23 @@ function deliveredContext(root, target, envelope, sessionId) {
393
395
  // thin wrappers bind the process clock and id source at the call site (§5 Stage 1).
394
396
  const issueCaptureToken = () => issueToken(randomUUID, Date.now());
395
397
  const consumeCaptureToken = (token) => consumeToken(token, Date.now());
398
+ async function askHumanToConfirm(server, message) {
399
+ if (!server.server.getClientCapabilities()?.elicitation?.form)
400
+ return "unavailable";
401
+ try {
402
+ const answer = await server.server.elicitInput({ mode: "form", message, requestedSchema: HUMAN_CONFIRMATION_SCHEMA });
403
+ return isHumanConfirmationAnswer(answer) ? "confirmed" : "declined";
404
+ }
405
+ catch {
406
+ return "unavailable";
407
+ }
408
+ }
409
+ function unconfirmedReason(c) {
410
+ return c === "declined"
411
+ ? "the human did not confirm it in the client prompt"
412
+ : "this client could not ask the human to confirm it (no MCP form elicitation, or the request failed)";
413
+ }
414
+ const clipForPrompt = (s, max = 400) => (s.length > max ? `${s.slice(0, max - 1)}…` : s);
396
415
  /** The interrogation protocol returned by hunch_capture_decision. With `deciding`,
397
416
  * the choice is NOT yet made: the verdict loop runs first so the record's
398
417
  * alternatives_rejected are attacks that actually ran — not post-hoc fiction. */
@@ -416,7 +435,7 @@ function grillingProtocol(topic, token, deciding = false) {
416
435
  "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
436
  `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
437
  "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.`,
438
+ `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
439
  "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
440
  "",
422
441
  "Required before commit: topic, title, decision, context (the rationale/why), alternatives_rejected. Missing any → keep grilling.",
@@ -436,23 +455,30 @@ function qualityNudge(rec) {
436
455
  }
437
456
  return "";
438
457
  }
439
- /** Resolve a free-form target (symbol id / name / file path) to symbol records. */
440
- function resolveSymbols(store, target) {
441
- target = toPosixTarget(target);
458
+ /** Resolve a free-form target (symbol id / name / file path) to symbol records.
459
+ * Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
460
+ * shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift
461
+ * apart on the same question. */
462
+ export function resolveSymbols(store, target) {
463
+ // An absolute target (an agent's edit-payload path, verbatim) never matched
464
+ // the repo-relative stored file paths below (issue #296).
465
+ target = repoRelativeTarget(target, store.publicRoot);
442
466
  const syms = store.json.loadAll("symbols");
443
- const byId = syms.find((s) => s.id === target);
444
- if (byId)
445
- return [byId];
446
- const byName = syms.filter((s) => s.name === target);
447
- if (byName.length)
448
- return byName;
449
- return syms.filter((s) => s.file === target || s.file.endsWith(target));
467
+ const components = store.json.loadAll("components");
468
+ // A target that IS a real indexed path (has a symbol, or is covered by an
469
+ // indexed component even with zero symbols — README.md, package.json, ...)
470
+ // never falls through to the suffix tier: bare `endsWith` matched unrelated
471
+ // files that merely end in the same characters — "db.ts" matched "mongodb.ts"
472
+ // (issue #300), and a same-basename file in another directory leaked its
473
+ // rules onto a real indexed target with no symbols of its own (issue #299).
474
+ const indexed = isIndexedPath(target, syms.map((s) => s.file), components.map((c) => c.paths));
475
+ return matchSymbolsTiered(target, syms, indexed);
450
476
  }
451
477
  /** Resolve a target to canonical indexed file path(s) (for file-granular blast
452
478
  * radius). Falls back to the literal target so direct-scope checks still run. */
453
- function resolveFiles(store, target) {
479
+ export function resolveFiles(store, target) {
454
480
  const files = new Set(resolveSymbols(store, target).map((s) => s.file));
455
- return files.size ? [...files] : [toPosixTarget(target)];
481
+ return files.size ? [...files] : [repoRelativeTarget(target, store.publicRoot)];
456
482
  }
457
483
  function pullBackoff(status, finishedAt, failures) {
458
484
  if (status === "updated" || status === "current") {
@@ -1426,7 +1452,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1426
1452
  // -- hunch_capture_decision (decision-grounding: the grilling front door) --
1427
1453
  server.registerTool("hunch_capture_decision", {
1428
1454
  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).",
1455
+ 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
1456
  inputSchema: {
1431
1457
  topic: z.string().optional().describe("proposed topic anchor (confirm with the human before committing)"),
1432
1458
  seed: z.string().optional().describe("what the decision is about, to focus the first question"),
@@ -1510,7 +1536,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1510
1536
  supersedes: z.string().optional().describe("id of a decision this one replaces — closes its valid-time window (invalidate, don't delete)"),
1511
1537
  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
1538
  }),
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)."),
1539
+ 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
1540
  task_id: TaskIdSchema.optional().describe("Exact task ID for observing this successful save; reporting never changes capture authority."),
1515
1541
  cwd: cwdHintField,
1516
1542
  },
@@ -1546,32 +1572,36 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1546
1572
  // from the commit) stay upgradeable by a different identity. Same-identity
1547
1573
  // re-record remains the countersign/refine path for every tier.
1548
1574
  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.
1575
+ // AUTHORSHIP STAMP (memory supply chain): human_confirmed needs a HUMAN act. Any
1576
+ // agent can CALL this tool mid-session, possibly steered by untrusted content it
1577
+ // read; "the human probably asked me to" is testimony, not a signature. A consumed
1578
+ // capture token proves only that hunch_capture_decision was called — also inside
1579
+ // the agent's channel — so it licenses ASKING the human (client elicitation, below)
1580
+ // and nothing more.
1553
1581
  //
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.)
1582
+ // Consumed HERE, before the overwrite guard, because the guard's answer depends on
1583
+ // it. (Consuming before a possible refusal burns the token, which is the safe
1584
+ // direction — a re-run of /capture mints another.)
1557
1585
  const gated = consumeCaptureToken(capture_token);
1558
1586
  const existingTiers = existing?.provenance.source.split("+") ?? [];
1559
1587
  const existingIsHuman = existingTiers.includes("human_confirmed");
1560
1588
  // A slot held only by AGENT TESTIMONY must not block a later human capture — the
1561
1589
  // 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
- }
1590
+ // capture"). A human_confirmed slot stays protected as before (issue #23): a
1591
+ // signature is never displaced by a differently-identified record, vouched or not.
1592
+ // Testimony yields only to a HUMAN-CONFIRMED write, so an un-tokened write is
1593
+ // refused now and a tokened one is refused below unless the human confirms.
1594
+ const slotConflict = curated && !sameHumanIdentity;
1595
+ const slotRefusal = () => refused(`Decision id ${id} already identifies a different curated decision: ` +
1596
+ `"${existing.title}"${existing.topic ? ` (topic "${existing.topic}")` : ""}. ` +
1597
+ `Refusing to overwrite it with "${decision.title}"${decision.topic ? ` (topic "${decision.topic}")` : ""}. ` +
1598
+ "Record the additional decision without commit, or reuse the incumbent topic/title when refining the same decision.");
1599
+ if (slotConflict && (existingIsHuman || !gated))
1600
+ return slotRefusal();
1572
1601
  // Un-token'd writes land as agent_recorded: fully functional advisory memory that
1573
1602
  // never carries human authority (strict/veto gates key on human_confirmed) and
1574
- // surfaces with a testimony marker. Re-record through /capture to countersign.
1603
+ // surfaces with a testimony marker. A human countersigns it (client prompt during
1604
+ // /capture, or `hunch review --confirm <id>`).
1575
1605
  //
1576
1606
  // A signature already on this slot is INHERITED, never erased. The un-token'd path
1577
1607
  // is exactly what the nudge below tells an agent to do ("re-record… supersedes"),
@@ -1580,10 +1610,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1580
1610
  // human had vouched for. That inverts the whole point of the stamp: it exists to
1581
1611
  // stop an agent CLAIMING human authority, not to let one DESTROY it. Downgrading a
1582
1612
  // 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;
1613
+ // The tier is resolved after the refusal guards below (it may need the human's
1614
+ // answer), so `rec` carries a placeholder provenance until then.
1587
1615
  const now = new Date().toISOString();
1588
1616
  const rec = {
1589
1617
  id,
@@ -1612,7 +1640,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1612
1640
  valid_from: existing?.valid_from ?? now,
1613
1641
  valid_to: existing?.valid_to ?? null,
1614
1642
  retired: existing?.retired ?? { symbols: [], deps: [] },
1615
- provenance: { source, confidence: gated ? 0.95 : 0.75, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
1643
+ provenance: { source: "agent_recorded", confidence: 0.75, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
1616
1644
  date: now,
1617
1645
  };
1618
1646
  // Where this write will actually land (see captureHome). Resolved BEFORE the
@@ -1641,6 +1669,31 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1641
1669
  `re-record with supersedes:<id> to replace it (linked, same store), pick a distinct topic to split, or discard this capture.`);
1642
1670
  }
1643
1671
  }
1672
+ // Human confirmation: asked only for a tokened write (the /capture tail — never
1673
+ // prompt-spam every agent write), only after every refusal guard has passed (never
1674
+ // ask a human to confirm a write that is then refused), and only through the client
1675
+ // UI, a channel the agent does not control.
1676
+ const confirmation = gated
1677
+ ? await askHumanToConfirm(server, [
1678
+ "Hunch: an agent is recording this engineering decision on your behalf. Confirm only if YOU made this decision.",
1679
+ "",
1680
+ `Title: ${clipForPrompt(rec.title, 200)}`,
1681
+ ...(rec.topic ? [`Topic: ${rec.topic}`] : []),
1682
+ `Status: ${rec.status}`,
1683
+ `Decision: ${clipForPrompt(rec.decision || "(none)")}`,
1684
+ ...(rec.alternatives_rejected.length ? [`Rejected: ${clipForPrompt(rec.alternatives_rejected.join("; "))}`] : []),
1685
+ "",
1686
+ "Confirmed, it carries your authority (human_confirmed). Unconfirmed, it is kept as agent testimony.",
1687
+ ].join("\n"))
1688
+ : null;
1689
+ const humanSigned = confirmation === "confirmed";
1690
+ if (slotConflict && !humanSigned)
1691
+ return slotRefusal();
1692
+ const tier = humanSigned || existingIsHuman ? "human_confirmed" : "agent_recorded";
1693
+ const source = existing && existing.provenance.source.includes("llm_draft")
1694
+ ? `llm_draft+${tier}`
1695
+ : tier;
1696
+ rec.provenance = { ...rec.provenance, source, confidence: humanSigned ? 0.95 : 0.75 };
1644
1697
  // Route the write to its ONE home: an explicit private:true goes to the overlay
1645
1698
  // (putPrivate throws rather than silently falling public); in unified ("shared")
1646
1699
  // mode EVERY capture goes to the overlay; else the public store.
@@ -1668,16 +1721,19 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1668
1721
  const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined, observed.observe);
1669
1722
  const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate, rec, hunchPaths(root).hunch) + observed.note;
1670
1723
  // 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.)`;
1724
+ // above. No token still writes (non-breaking) but lands as agent_recorded with a
1725
+ // nudge toward /capture. A token — verified or not — is never a signature: only
1726
+ // the human's confirmation (client prompt or `hunch review --confirm`) is.
1727
+ const confirmCmd = confirmCommand(id, { private: home === "private" });
1728
+ const captureNote = humanSigned
1729
+ ? " [via capture front door — confirmed by the human in the client]"
1730
+ : existingIsHuman
1731
+ ? " [the existing human signature on this record is retained]"
1732
+ : gated
1733
+ ? `\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}`
1734
+ : capture_token
1735
+ ? `\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}`
1736
+ : `\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
1737
  // Quality nudge only when the untokened deprecation nudge isn't already
1682
1738
  // grilling — one advisory voice per response, never two.
1683
1739
  const quality = gated || capture_token ? qualityNudge(rec) : "";
@@ -1712,7 +1768,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1712
1768
  rationale: z.string().optional().describe("Why it must hold."),
1713
1769
  source_decision: z.string().optional().describe("id of a decision this correction derives from."),
1714
1770
  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'."),
1771
+ 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
1772
  task_id: TaskIdSchema.optional().describe("Exact task ID for observing this successful save; reporting never changes capture authority."),
1717
1773
  cwd: cwdHintField,
1718
1774
  },
@@ -1724,19 +1780,28 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1724
1780
  // paths (edit-tool payloads and MCP roots are absolute) and every consumer matches
1725
1781
  // repo-relative — without this the rule would be blocking-but-inert and would leak
1726
1782
  // 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());
1783
+ // AUTHORSHIP TIER. A correction recorded through MCP is ALWAYS agent testimony: a
1784
+ // capture token (callable and consumable by any agent) proves only that the
1785
+ // interview tool was called, and an in-client confirmation is not used here either.
1786
+ // The stakes are higher than for a decision — a blocking constraint DENIES edits
1787
+ // (the edit hook keys on severity alone) and fails strict checks — so blocking
1788
+ // authority comes only from a human running `hunch review --confirm <id> --severity <s>`
1789
+ // outside the agent channel. The write is capped below blocking rather than refused:
1790
+ // Never Twice still lands immediately and is surfaced at edit time and in CI.
1791
+ const gated = consumeCaptureToken(input.capture_token);
1792
+ const now = new Date().toISOString();
1793
+ const knownDeps = knownRepoDeps(root);
1794
+ // What a human confirmation would grant: the severity the caller requested, after
1795
+ // the repo-wide scope guard.
1796
+ const requested = buildCorrectionConstraint({ ...input, knownDeps, root, vouched: true }, now);
1733
1797
  // Private corrections go to the overlay (enforced locally via the merged read,
1734
1798
  // never rendered into the public CI comment, which is public-only by construction).
1735
1799
  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}.`);
1800
+ if (home === "public" && requested.source_decision && !store.json.get("decisions", requested.source_decision)) {
1801
+ const location = store.getPrivateRec("decisions", requested.source_decision) ? "exists only in the private overlay" : "does not exist in the public home";
1802
+ return refused(`source decision ${requested.source_decision} ${location}; refusing to record public correction ${requested.id}.`);
1739
1803
  }
1804
+ const rec = buildCorrectionConstraint({ ...input, knownDeps, root, vouched: false }, now);
1740
1805
  const existing = home === "private" ? store.getPrivateRec("constraints", rec.id) : store.json.get("constraints", rec.id);
1741
1806
  // Same cross-home twin guard as the decision path above.
1742
1807
  const stored = store.putCapture("constraints", rec, !!input.private);
@@ -1765,11 +1830,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1765
1830
  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
1831
  // Say plainly which tier this landed in. A silent downgrade would be its own
1767
1832
  // dishonesty: the caller asked for "blocking" and must be told it is not.
1768
- const tierNote = vouched
1769
- ? ""
1770
- : `
1833
+ const capped = requested.severity !== rec.severity;
1834
+ const why = gated
1835
+ ? "Recorded after a capture interview, but a capture token is not a human signature"
1836
+ : "Recorded WITHOUT a capture interview or a human confirmation";
1837
+ const tierNote = `
1771
1838
 
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).`;
1839
+ ⚠ ${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
1840
  const dest = destinationNote(resolveDestRoot(home, store, root));
1774
1841
  return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where}${dest} It now ${enforce}.${reviewNote}${tierNote}`);
1775
1842
  }
@@ -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;
package/dist/serve/app.js CHANGED
@@ -93,6 +93,7 @@ function parseScopeParam(value) {
93
93
  }
94
94
  export function createServeApp(config, opts = {}) {
95
95
  const version = opts.version ?? HUNCH_VERSION;
96
+ const writeLockTimeoutMs = opts.writeLockTimeoutMs;
96
97
  const configFile = config.file;
97
98
  const authStateDir = opts.authStateDir ?? (configFile ? resolve(configFile + ".auth") : undefined);
98
99
  const stores = new Map();
@@ -125,19 +126,26 @@ export function createServeApp(config, opts = {}) {
125
126
  };
126
127
  const problemBody = (p) => ({ type: `${PROBLEM_TYPE}${p.code}`, title: p.code, status: p.status, detail: p.message, ...p.extra });
127
128
  const sendProblem = (res, p) => { send(res, p.status, problemBody(p), "application/problem+json"); };
128
- /** Anything thrown → problem. Shared by REST (problem+json) and MCP (tool error results). */
129
+ const log = opts.log ?? ((line) => { process.stderr.write(`${line}\n`); });
130
+ /** Anything thrown → problem. Shared by REST (problem+json) and MCP (tool error results).
131
+ * A contract refusal (4xx) explains itself to the caller. A server-side failure (5xx) does
132
+ * not: its message can name lock paths, PIDs, host names or store paths, so the caller gets
133
+ * a generic detail and the specifics go to the server log only. */
129
134
  const problemOf = (error) => {
130
135
  if (error instanceof HttpProblem)
131
136
  return error;
132
137
  if (error instanceof StateRefusal)
133
138
  return problem(REFUSAL_STATUS[error.code], error.code, error.message, error.conflict ? { conflict: error.conflict } : {});
134
- if (error instanceof WriteLockTimeout)
135
- return problem(503, "write-lock-timeout", error.message, { "retry-after": 1 });
139
+ if (error instanceof WriteLockTimeout) {
140
+ log(`hunch serve: 503 write-lock-timeout: ${error.message}`);
141
+ return problem(503, "write-lock-timeout", "the partition is busy with another write; retry shortly", { "retry-after": 1 });
142
+ }
136
143
  if (error && typeof error === "object" && error.name === "ZodError") {
137
144
  const issues = (error.issues ?? []).map((i) => `${i.path.join(".") || "request"}: ${i.message}`);
138
145
  return problem(400, "malformed", `request is malformed: ${issues.join("; ")}`, { issues });
139
146
  }
140
- return problem(500, "internal", error.message);
147
+ log(`hunch serve: 500 internal: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
148
+ return problem(500, "internal", "internal server error");
141
149
  };
142
150
  /** One authenticated state verb. The REST routes and the MCP tools both call this, so every
143
151
  * rule — grants, the write lock, flushes, refusals — is the same whichever transport carried it. */
@@ -191,20 +199,53 @@ export function createServeApp(config, opts = {}) {
191
199
  if (route === "write" || route === "capture" || route === "capture-batch") {
192
200
  const { hunchDir } = stateHomeFor(store, scope);
193
201
  const opts = { ...accessOptions, flush };
202
+ const lockOptions = { timeoutMs: writeLockTimeoutMs };
194
203
  if (route === "write") {
195
- const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, opts));
204
+ const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, opts), lockOptions);
196
205
  return { status: result.outcome === "created" ? 201 : 200, payload: result };
197
206
  }
198
207
  if (route === "capture") {
199
- const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, opts));
208
+ const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, opts), lockOptions);
200
209
  return { status: result.outcome === "created" ? 201 : 200, payload: result };
201
210
  }
202
- return { status: 200, payload: await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, opts)) };
211
+ return { status: 200, payload: await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, opts), lockOptions) };
203
212
  }
204
213
  if (route === "subscribe")
205
214
  return { status: 200, payload: subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, principal, ...body }, accessOptions) };
206
215
  return { status: 200, payload: recordsState(store, { schema: STATE_RECORDS_VERSION, principal, ...body }, accessOptions) };
207
216
  };
217
+ /** The bearer/DPoP credential → the principal. Every authenticated route, and health when a
218
+ * credential is presented, goes through this one check. */
219
+ const authenticate = async (req, res, url, activeConfig) => {
220
+ const authorization = /^(Bearer|DPoP) ([^\s]+)$/i.exec(req.headers.authorization ?? '');
221
+ const credential = resolveCredential(activeConfig, authorization?.[2]);
222
+ if (!credential)
223
+ throw problem(401, 'unauthorized', 'valid credentials are required');
224
+ const countHeader = (name) => req.rawHeaders.filter((header, index) => index % 2 === 0 && header.toLowerCase() === name).length;
225
+ if (countHeader('authorization') !== 1 || countHeader('dpop') > 1)
226
+ throw problem(401, 'invalid_dpop_proof', 'ambiguous authentication headers');
227
+ if (credential.proof_key) {
228
+ if (authorization[1].toLowerCase() !== 'dpop')
229
+ throw problem(401, 'invalid_dpop_proof', 'this credential requires DPoP proof; bearer fallback is disabled');
230
+ if (!activeConfig.public_origin || !authStateDir)
231
+ throw problem(503, 'proof-state-unavailable', 'key-bound authentication requires a public origin and persistent proof state');
232
+ try {
233
+ await verifyStateProof({ proof: typeof req.headers.dpop === 'string' ? req.headers.dpop : undefined, key: credential.proof_key, method: req.method ?? '', url: activeConfig.public_origin + url.pathname, token: authorization[2], stateDir: authStateDir });
234
+ }
235
+ catch (error) {
236
+ if (error instanceof StateProofError) {
237
+ res.setHeader('WWW-Authenticate', `DPoP error="${error.code}", algs="EdDSA"`);
238
+ if (error.nonce)
239
+ res.setHeader('DPoP-Nonce', error.nonce);
240
+ throw problem(401, error.code, error.message);
241
+ }
242
+ throw problem(503, 'proof-state-unavailable', 'proof replay state is unavailable; authentication is refused');
243
+ }
244
+ }
245
+ else if (authorization[1].toLowerCase() !== 'bearer' || req.headers.dpop !== undefined)
246
+ throw problem(401, 'invalid_dpop_proof', 'credential is not bound to a proof key');
247
+ return { id: credential.id, kind: credential.kind, grants: credential.grants, ...(credential.display ? { display: credential.display } : {}) };
248
+ };
208
249
  const server = createServer(async (req, res) => {
209
250
  try {
210
251
  let activeConfig;
@@ -222,36 +263,15 @@ export function createServeApp(config, opts = {}) {
222
263
  return res.end(asset[0]);
223
264
  }
224
265
  if (url.pathname === "/nuryel/v1/health" && req.method === "GET") {
225
- return send(res, 200, { ok: true, version, protocol: "nuryel.state/1", partitions: activeConfig.partitions.map((p) => scopePath(p.scope)) });
226
- }
227
- const authorization = /^(Bearer|DPoP) ([^\s]+)$/i.exec(req.headers.authorization ?? '');
228
- const credential = resolveCredential(activeConfig, authorization?.[2]);
229
- if (!credential)
230
- throw problem(401, 'unauthorized', 'valid credentials are required');
231
- const countHeader = (name) => req.rawHeaders.filter((header, index) => index % 2 === 0 && header.toLowerCase() === name).length;
232
- if (countHeader('authorization') !== 1 || countHeader('dpop') > 1)
233
- throw problem(401, 'invalid_dpop_proof', 'ambiguous authentication headers');
234
- if (credential.proof_key) {
235
- if (authorization[1].toLowerCase() !== 'dpop')
236
- throw problem(401, 'invalid_dpop_proof', 'this credential requires DPoP proof; bearer fallback is disabled');
237
- if (!activeConfig.public_origin || !authStateDir)
238
- throw problem(503, 'proof-state-unavailable', 'key-bound authentication requires a public origin and persistent proof state');
239
- try {
240
- await verifyStateProof({ proof: typeof req.headers.dpop === 'string' ? req.headers.dpop : undefined, key: credential.proof_key, method: req.method ?? '', url: activeConfig.public_origin + url.pathname, token: authorization[2], stateDir: authStateDir });
241
- }
242
- catch (error) {
243
- if (error instanceof StateProofError) {
244
- res.setHeader('WWW-Authenticate', `DPoP error="${error.code}", algs="EdDSA"`);
245
- if (error.nonce)
246
- res.setHeader('DPoP-Nonce', error.nonce);
247
- throw problem(401, error.code, error.message);
248
- }
249
- throw problem(503, 'proof-state-unavailable', 'proof replay state is unavailable; authentication is refused');
250
- }
266
+ // Liveness is public (a load balancer or proxy probe holds no token). Which
267
+ // partitions this server hosts is not: their ids name people and organizations.
268
+ const liveness = { ok: true, version, protocol: "nuryel.state/1" };
269
+ if (req.headers.authorization === undefined && req.headers.dpop === undefined)
270
+ return send(res, 200, liveness);
271
+ await authenticate(req, res, url, activeConfig);
272
+ return send(res, 200, { ...liveness, partitions: activeConfig.partitions.map((p) => scopePath(p.scope)) });
251
273
  }
252
- else if (authorization[1].toLowerCase() !== 'bearer' || req.headers.dpop !== undefined)
253
- throw problem(401, 'invalid_dpop_proof', 'credential is not bound to a proof key');
254
- const principal = { id: credential.id, kind: credential.kind, grants: credential.grants, ...(credential.display ? { display: credential.display } : {}) };
274
+ const principal = await authenticate(req, res, url, activeConfig);
255
275
  if (url.pathname === "/nuryel/v1/capabilities" && req.method === "GET") {
256
276
  const scope = parseScopeParam(url.searchParams.get("scope"));
257
277
  const { status, payload } = await dispatch("capabilities", principal, scope ? { scope } : {}, activeConfig);
@@ -3,11 +3,25 @@
3
3
  export declare const STALE_AFTER_MS = 60000;
4
4
  /** Longest a writer waits before giving up and telling the caller. */
5
5
  export declare const ACQUIRE_TIMEOUT_MS = 10000;
6
+ /** Longest a takeover claim may sit before another contender may clear it. The
7
+ * claimed section is a few syscalls, so anything this old means its claimer
8
+ * crashed inside it. */
9
+ export declare const CLAIM_STALE_MS = 10000;
6
10
  export interface LockOwner {
7
11
  pid: number;
8
12
  host: string;
9
13
  nonce: string;
10
14
  at: string;
15
+ start?: string;
16
+ }
17
+ /** Token seam: the caller's own process-instance token and a probe for another
18
+ * pid's, plus the platform the decision table should reason about (the own-pid
19
+ * fallback differs on linux, where pid namespaces exist). Injected by tests so
20
+ * the identity paths are exercised off linux. */
21
+ export interface StartTokenProbe {
22
+ self: string | null;
23
+ of: (pid: number) => string | null;
24
+ platform?: NodeJS.Platform;
11
25
  }
12
26
  export declare class WriteLockTimeout extends Error {
13
27
  readonly path: string;
@@ -16,7 +30,13 @@ export declare class WriteLockTimeout extends Error {
16
30
  constructor(path: string, heldBy: LockOwner | undefined, waitedMs: number);
17
31
  }
18
32
  export declare function writeLockPath(hunchDir: string): string;
19
- /** Hold the partition's lock across `fn` (sync or async); released even if it throws. */
33
+ /** Hold the partition's lock across `fn` (sync or async); released even if it throws.
34
+ *
35
+ * `opts.startToken` is an INTERNAL TEST SEAM, not API: it lets the test suite
36
+ * drive the process-identity rows of the decision table on a platform that has
37
+ * no such identity. Callers outside this repo must leave it unset — the real
38
+ * probe (`selfStartToken`/`startTokenOf`) is the only sound one. */
20
39
  export declare function withWriteLock<T>(hunchDir: string, fn: () => T | Promise<T>, opts?: {
21
40
  timeoutMs?: number;
41
+ startToken?: StartTokenProbe;
22
42
  }): Promise<T>;