@davesheffer/hunch 1.39.1 → 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.
@@ -24,6 +24,7 @@ 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";
@@ -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
  }
@@ -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);
@@ -275,15 +275,18 @@ export function readState(store, input, options = {}) {
275
275
  }
276
276
  if (facets.has("derived"))
277
277
  for (const d of store.recs("derived")) {
278
+ // Match the subject BEFORE admit, as every other facet does: admit names an ungranted
279
+ // partition in denied_scopes, and only a record that matches may name its partition.
280
+ // A linked observation must sit in the request's (granted) partition, so it never names one.
281
+ const direct = isSubject(d.subject) || d.id === subject;
282
+ const linked = scopePath(recordScope(d, repo)) === scopePath(request.scope) && linkedObservations.get(d.id)?.has(stateHash(d));
283
+ if (!direct && !linked)
284
+ continue;
278
285
  const scope = admit("derived", d);
279
286
  if (!scope)
280
287
  continue;
281
288
  if (request.observed_page && scopePath(scope) !== scopePath(request.scope))
282
289
  continue;
283
- const direct = isSubject(d.subject) || d.id === subject;
284
- const linked = scopePath(scope) === scopePath(request.scope) && linkedObservations.get(d.id)?.has(stateHash(d));
285
- if (!direct && !linked)
286
- continue;
287
290
  if (!direct) {
288
291
  if (d.state === "unknown" && d.valid_to == null)
289
292
  observations.push(d);
@@ -725,7 +728,17 @@ function writeStateAuthorized(store, input, opts, access) {
725
728
  catch (e) {
726
729
  throw new StateRefusal(/grants/.test(e.message) ? "outside-grants" : "malformed", e.message);
727
730
  }
731
+ const repo = partitionOf(store);
732
+ // Legacy kinds carry no partition scope: whatever home they land in, every reader (and the
733
+ // repository's edit gate) treats them as the store's OWN partition. Written under any other
734
+ // scope they would silently become that partition's records, so they are refused instead.
735
+ if (LEGACY_FACETS.has(request.facet) && scopePath(request.scope) !== scopePath(repo)) {
736
+ throw new StateRefusal("unsupported", `${request.facet} records belong to this store's own partition ${scopePath(repo)}; they cannot be written under ${scopePath(request.scope)} — use a partition-scoped facet (conventions, receipts, commitments, derived, entities, relationships) there`);
737
+ }
728
738
  const { home, hunchDir, isPrivate } = stateHomeFor(store, request.scope);
739
+ /** Several partitions share one overlay home: a record counts for this write only when it is
740
+ * in the write's own partition. */
741
+ const inScope = (r) => scopePath(recordScope(r, repo)) === scopePath(request.scope);
729
742
  const now = (opts.now ?? (() => new Date()))().toISOString();
730
743
  const facet = request.facet;
731
744
  const getHere = (id) => facet === "derived" || facet === "receipts" || facet === "commitments"
@@ -765,6 +778,10 @@ function writeStateAuthorized(store, input, opts, access) {
765
778
  const incumbent = getHere(String(record.id));
766
779
  if (incumbent && !access.canWrite(incumbent))
767
780
  deny();
781
+ // An id not derived from its scope (an entity, a relationship) can collide with another
782
+ // partition's record in a shared home: that record is never rewritten from this partition.
783
+ if (incumbent && !inScope(incumbent))
784
+ throw new StateRefusal("conflict", `${id} is on record in ${scopePath(recordScope(incumbent, repo))}, not ${scopePath(request.scope)}: a write never moves or rewrites another partition's record`, { incumbent_id: id, reason: "record id held by another partition" });
768
785
  const previousVisibility = incumbent?.visibility;
769
786
  const nextVisibility = record.visibility;
770
787
  if (nextVisibility && home === 'private')
@@ -774,6 +791,10 @@ function writeStateAuthorized(store, input, opts, access) {
774
791
  if (supersededRecord) {
775
792
  if (!access.canWrite(supersededRecord))
776
793
  deny();
794
+ // Supersession stays inside one partition: closing another partition's record from here would
795
+ // land its `superseded` event in THIS ledger, invisible to that partition's subscribers.
796
+ if (!inScope(supersededRecord))
797
+ throw new StateRefusal("conflict", `supersedes ${request.supersedes} is on record in ${scopePath(recordScope(supersededRecord, repo))}, not ${scopePath(request.scope)}: a write supersedes only a record in its own partition`, { incumbent_id: String(supersededRecord.id), reason: "supersede target in another partition" });
777
798
  if (stateHash(supersededVisibility ?? null) !== stateHash(nextVisibility ?? null)) {
778
799
  if (supersededVisibility ? supersededVisibility.owner !== request.principal.id : request.principal.kind !== 'human')
779
800
  deny();
@@ -921,7 +942,7 @@ function writeStateAuthorized(store, input, opts, access) {
921
942
  }
922
943
  }
923
944
  }
924
- if (supersedes && !store.recsInHome(facet, home).some((r) => r.id === supersedes)) {
945
+ if (supersedes && !store.recsInHome(facet, home).some((r) => r.id === supersedes && inScope(r))) {
925
946
  throw new StateRefusal("conflict", `supersedes ${supersedes} is not a ${facet} record in this partition`, { incumbent_id: supersedes, reason: "supersede target absent" });
926
947
  }
927
948
  if (supersedes === id)
@@ -935,7 +956,7 @@ function writeStateAuthorized(store, input, opts, access) {
935
956
  if (incumbent && "valid_to" in incumbent && incumbent.valid_to !== null) {
936
957
  const subject = subjectOf(facet, incumbent);
937
958
  const open = store.recsInHome(facet, home)
938
- .filter((r) => subjectOf(facet, r) === subject && r.valid_to === null)
959
+ .filter((r) => inScope(r) && subjectOf(facet, r) === subject && r.valid_to === null)
939
960
  .map((r) => r.id).sort();
940
961
  if (!open.includes(id)) {
941
962
  const current = open.length ? `the current ${facet} record for ${subject ?? "that subject"} is ${open.join(", ")}` : `no ${facet} record for ${subject ?? "that subject"} is open now`;
@@ -954,7 +975,7 @@ function writeStateAuthorized(store, input, opts, access) {
954
975
  const d = record;
955
976
  const incumbent = store.recsInHome("derived", home).find((r) => {
956
977
  const x = r;
957
- return x.id !== id && x.id !== supersedes && x.subject === d.subject && x.transform_version === d.transform_version && x.state === "current" && x.valid_to === null;
978
+ return x.id !== id && x.id !== supersedes && inScope(x) && x.subject === d.subject && x.transform_version === d.transform_version && x.state === "current" && x.valid_to === null;
958
979
  });
959
980
  if (incumbent) {
960
981
  throw new StateRefusal("conflict", `${d.subject} already has a current ${d.transform_version} statement ${incumbent.id}; pass supersedes: "${incumbent.id}" to replace it, or write that identity to update it`, { incumbent_id: incumbent.id, reason: "one-current-derived-per-subject-transform" });
@@ -146,7 +146,14 @@ export interface NowItem {
146
146
  date: string;
147
147
  /** decision text for recent items; CONTEXT (the why-it's-planned) for roadmap items. */
148
148
  note: string;
149
+ /** Roadmap only: true when the item is agent testimony no human has confirmed yet.
150
+ * Omitted (never false) otherwise, so pages rendered before this field stay fresh. */
151
+ unconfirmed?: true;
149
152
  }
153
+ /** Marker for an unconfirmed roadmap item, naming the human countersign command. */
154
+ export declare function unconfirmedRoadmapMarker(item: Pick<NowItem, "id" | "unconfirmed">, opts?: {
155
+ private?: boolean;
156
+ }): string;
150
157
  /** The hot view's inputs: last `recentLimit` decisions by date (any status — a
151
158
  * supersession IS activity), and every live PROPOSED decision (the roadmap:
152
159
  * record intent as a proposed decision; accepting or superseding it removes it