@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.
- package/dist/cli/index.js +67 -5
- package/dist/client/state.d.ts +2 -1
- package/dist/client/state.js +1 -0
- package/dist/core/agenthook.js +7 -3
- package/dist/core/capturetoken.d.ts +30 -3
- package/dist/core/capturetoken.js +29 -3
- package/dist/core/correction.d.ts +10 -4
- package/dist/core/correction.js +7 -4
- package/dist/core/countersign.d.ts +28 -0
- package/dist/core/countersign.js +50 -0
- package/dist/core/reviewqueue.js +6 -1
- package/dist/core/spawnCommand.js +41 -9
- package/dist/core/stateHttp.d.ts +1 -1
- package/dist/core/stateHttp.js +3 -1
- package/dist/core/taskReportEvidence.js +25 -5
- package/dist/core/topics.js +1 -1
- package/dist/integrations/scaffold.js +1 -1
- package/dist/mcp/server.js +110 -51
- package/dist/serve/app.d.ts +4 -0
- package/dist/serve/app.js +56 -36
- package/dist/store/stateBinding.js +28 -7
- package/dist/wiki/wiki.d.ts +7 -0
- package/dist/wiki/wiki.js +19 -8
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/mcp/server.js
CHANGED
|
@@ -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):
|
|
1550
|
-
//
|
|
1551
|
-
//
|
|
1552
|
-
//
|
|
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
|
-
//
|
|
1555
|
-
// it
|
|
1556
|
-
//
|
|
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")
|
|
1563
|
-
//
|
|
1564
|
-
//
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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.
|
|
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
|
-
|
|
1584
|
-
|
|
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:
|
|
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
|
|
1672
|
-
//
|
|
1673
|
-
//
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
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
|
|
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
|
-
//
|
|
1728
|
-
//
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
|
|
1732
|
-
|
|
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" &&
|
|
1737
|
-
const location = store.getPrivateRec("decisions",
|
|
1738
|
-
return refused(`source decision ${
|
|
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
|
|
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
|
-
⚠
|
|
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
|
}
|
package/dist/serve/app.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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" });
|
package/dist/wiki/wiki.d.ts
CHANGED
|
@@ -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
|