@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.
- package/dist/cli/index.js +164 -55
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- 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 +19 -11
- package/dist/core/countersign.d.ts +28 -0
- package/dist/core/countersign.js +50 -0
- package/dist/core/docanchors.js +16 -2
- package/dist/core/glob.d.ts +19 -0
- package/dist/core/glob.js +37 -0
- package/dist/core/paths.d.ts +27 -5
- package/dist/core/paths.js +51 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/reviewqueue.js +6 -1
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- 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/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +42 -6
- package/dist/core/taskReportHook.js +14 -2
- package/dist/core/topics.js +1 -1
- package/dist/core/workspace.d.ts +9 -0
- package/dist/core/workspace.js +15 -0
- package/dist/extractors/git.d.ts +5 -0
- package/dist/extractors/git.js +87 -10
- package/dist/extractors/indexer.d.ts +10 -0
- package/dist/extractors/indexer.js +17 -1
- package/dist/extractors/k8sManifest.js +104 -16
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +38 -7
- package/dist/integrations/hooks.js +250 -24
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +25 -22
- package/dist/integrations/workspaceLedger.d.ts +10 -0
- package/dist/integrations/workspaceLedger.js +9 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +131 -64
- package/dist/serve/app.d.ts +4 -0
- package/dist/serve/app.js +56 -36
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/hunchStore.js +41 -11
- package/dist/store/jsonStore.d.ts +6 -4
- package/dist/store/jsonStore.js +225 -32
- package/dist/store/merge.d.ts +7 -4
- package/dist/store/merge.js +65 -5
- package/dist/store/privateMigrate.js +10 -0
- package/dist/store/stateBinding.js +28 -7
- package/dist/wiki/adopt.js +13 -2
- 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
|
@@ -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
|
-
|
|
441
|
-
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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] : [
|
|
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):
|
|
1550
|
-
//
|
|
1551
|
-
//
|
|
1552
|
-
//
|
|
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
|
-
//
|
|
1555
|
-
// it
|
|
1556
|
-
//
|
|
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")
|
|
1563
|
-
//
|
|
1564
|
-
//
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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.
|
|
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
|
-
|
|
1584
|
-
|
|
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:
|
|
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
|
|
1672
|
-
//
|
|
1673
|
-
//
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
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
|
|
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
|
-
//
|
|
1728
|
-
//
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
|
|
1732
|
-
|
|
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" &&
|
|
1737
|
-
const location = store.getPrivateRec("decisions",
|
|
1738
|
-
return refused(`source decision ${
|
|
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
|
|
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
|
-
⚠
|
|
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
|
}
|
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);
|
|
@@ -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>;
|