@davesheffer/hunch 1.45.2 → 1.47.0
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 +290 -353
- package/dist/cli/invocation.d.ts +22 -13
- package/dist/cli/invocation.js +49 -29
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/agenthook.js +26 -6
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/correction.js +9 -9
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- package/dist/core/docanchors.js +9 -9
- package/dist/core/drift.js +22 -27
- package/dist/core/escalations.d.ts +16 -58
- package/dist/core/escalations.js +39 -121
- package/dist/core/glob.d.ts +7 -7
- package/dist/core/glob.js +12 -11
- package/dist/core/groundingLag.d.ts +2 -9
- package/dist/core/groundingLag.js +5 -11
- package/dist/core/groundingMerge.d.ts +2 -7
- package/dist/core/groundingMerge.js +35 -54
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/projectDna.js +11 -1
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/extractors/diff.d.ts +4 -4
- package/dist/extractors/diff.js +25 -29
- package/dist/extractors/git.d.ts +8 -23
- package/dist/extractors/git.js +77 -95
- package/dist/extractors/helm.d.ts +10 -0
- package/dist/extractors/helm.js +43 -59
- package/dist/extractors/indexer.d.ts +6 -0
- package/dist/extractors/indexer.js +168 -184
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +37 -37
- package/dist/extractors/languages.js +83 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +27 -13
- package/dist/extractors/parse.js +120 -56
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +13 -16
- package/dist/integrations/hooks.js +31 -30
- package/dist/integrations/mergeDriver.js +18 -17
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +4 -2
- package/dist/integrations/uninstall.d.ts +10 -4
- package/dist/integrations/uninstall.js +22 -18
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +107 -81
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +3 -1
- package/dist/serve/writelock.js +17 -3
- package/dist/store/hunchStore.d.ts +2 -1
- package/dist/store/hunchStore.js +20 -23
- package/dist/store/jsonStore.d.ts +13 -3
- package/dist/store/jsonStore.js +37 -13
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +93 -154
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +33 -50
- package/package.json +3 -3
- package/server.json +2 -2
package/dist/mcp/server.js
CHANGED
|
@@ -69,7 +69,7 @@ import { nowData, wikiStatus, publicHome, readWikiManifestAt } from "../wiki/wik
|
|
|
69
69
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
70
70
|
import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
|
|
71
71
|
import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
|
|
72
|
-
import { formatWhyRecord, more, WHY_CAP } from "../core/whyRecord.js"; // WHY_CAP: per record-type cap in hunch_why
|
|
72
|
+
import { formatWhyRecord, more, WHY_CAP, WHY_RECORD_ID } from "../core/whyRecord.js"; // WHY_CAP: per record-type cap in hunch_why
|
|
73
73
|
import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline } from "../core/escalations.js";
|
|
74
74
|
import { readActivePendingRepairs, withheldRewrites } from "../core/repairqueue.js";
|
|
75
75
|
import { scanRecord, publicationWarning, loadVocabulary } from "../core/publication.js";
|
|
@@ -90,24 +90,25 @@ const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
|
90
90
|
* Transient states that say "retry" in their own words stay unprefixed. */
|
|
91
91
|
const refused = (text) => err(`Refused: ${text}`);
|
|
92
92
|
const invalid = (text) => err(`Invalid: ${text}`);
|
|
93
|
-
/** Shared by every auto-committing write tool
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
93
|
+
/** Shared by every auto-committing write tool. The MCP roots protocol cannot observe an
|
|
94
|
+
* agent-driven directory change or worktree entry, so a stdio server's cached root never
|
|
95
|
+
* moves by itself; this field is the client-agnostic fallback, resolved fresh on every
|
|
96
|
+
* call by the generic tool wrapper. */
|
|
97
97
|
const cwdHintField = z.string().optional().describe("Your current directory, when it differs from where this session started (e.g. a worktree); " +
|
|
98
98
|
"writes then commit there, not to the original root.");
|
|
99
99
|
/** Shared by hunch_record_decision/correction/finding — identical text repeated 3x
|
|
100
100
|
* otherwise. hunch_context's task_id field differs (delivery vs. save) and keeps its
|
|
101
101
|
* own description. */
|
|
102
102
|
const saveTaskIdField = TaskIdSchema.optional().describe("Task ID, to attribute this save to it; never affects capture authority.");
|
|
103
|
-
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
|
|
107
|
-
|
|
103
|
+
/** Pulls the cwd hint out of a tool call's already-parsed first argument. The wrapper
|
|
104
|
+
* runs for EVERY tool (read and write), so this assumes nothing about any one tool's
|
|
105
|
+
* input shape: anything that is not an object with a non-blank string `cwd` yields
|
|
106
|
+
* undefined. The original (untrimmed) string is returned. */
|
|
107
|
+
function cwdHintOf(input) {
|
|
108
|
+
if (typeof input !== "object" || input === null)
|
|
108
109
|
return undefined;
|
|
109
110
|
const cwd = input.cwd;
|
|
110
|
-
return typeof cwd === "string" && cwd.trim() ? cwd : undefined;
|
|
111
|
+
return typeof cwd === "string" && cwd.trim() !== "" ? cwd : undefined;
|
|
111
112
|
}
|
|
112
113
|
/** Honest auto-commit suffix: reports only what flushCapture ACTUALLY did. A skipped
|
|
113
114
|
* commit (backstop/lock/nothing staged) says nothing — the record is on disk and the
|
|
@@ -150,11 +151,9 @@ const publicHomeNote = (home, hasPrivate, record, hunchDir) => {
|
|
|
150
151
|
return risk;
|
|
151
152
|
return "\nℹ Landed in the COMMITTED PUBLIC store (publishes with the repo). For sensitive/strategy content, re-record with private:true — the overlay store." + risk;
|
|
152
153
|
};
|
|
153
|
-
/** Self-diagnosing destination
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* appends this so the destination is always visible in the response, whether or
|
|
157
|
-
* not a cwd hint was involved in choosing it. */
|
|
154
|
+
/** Self-diagnosing destination suffix for every auto-committing write tool. A capture
|
|
155
|
+
* silently landing in the wrong repo or branch is only discoverable by auditing git log
|
|
156
|
+
* by hand, so the destination is ALWAYS shown, whether or not a cwd hint chose it. */
|
|
158
157
|
const destinationNote = (destRoot) => {
|
|
159
158
|
const branch = currentBranch(destRoot);
|
|
160
159
|
return ` [captured${branch ? ` on branch ${branch}` : ""} in ${destRoot}]`;
|
|
@@ -586,12 +585,10 @@ function nuryelWriteSubject(facet, record) {
|
|
|
586
585
|
const kind = facet.endsWith("s") ? facet.slice(0, -1) : facet;
|
|
587
586
|
return `${kind}${typeof label === "string" ? ` "${label.slice(0, 60)}"` : ""} (nuryel_write)`;
|
|
588
587
|
}
|
|
589
|
-
/** Where a capture keyed to
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
|
|
593
|
-
* regardless), silently misreporting the destination for a private-homed proof. */
|
|
594
|
-
const resolveDestRoot = (home, store, root) => home === "private" && store.privateDir ? store.privateDir : root;
|
|
588
|
+
/** Where a capture keyed to a storage home actually lands. Centralized so every
|
|
589
|
+
* destination-reporting call site agrees: hunch_policy_upgrade_correction once computed
|
|
590
|
+
* its own home but reported the public root regardless, misreporting a private-homed proof. */
|
|
591
|
+
const destinationRoot = (home, store, root) => home === "private" && store.privateDir ? store.privateDir : root;
|
|
595
592
|
// Read-side token budgets: every tool result is injected into a Claude Code
|
|
596
593
|
// session, so an uncapped list pollutes the context window. Cap each list to its
|
|
597
594
|
// highest-signal head (records are pre-sorted by severity/confidence) and tell the
|
|
@@ -993,13 +990,12 @@ function qualityNudge(rec) {
|
|
|
993
990
|
}
|
|
994
991
|
return "";
|
|
995
992
|
}
|
|
996
|
-
/** Resolve a free-form target (symbol id
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
* apart on the same question. */
|
|
993
|
+
/** Resolve a free-form target (symbol id, name, or file path) to symbol records using
|
|
994
|
+
* tiered matching — exact id, then exact name, then exact file, then a path-segment-anchored
|
|
995
|
+
* suffix. Shared with HunchStore.why() through matchSymbolsTiered so the two never disagree. */
|
|
1000
996
|
export function resolveSymbols(store, target) {
|
|
1001
|
-
// An absolute
|
|
1002
|
-
//
|
|
997
|
+
// An absolute path (an agent's verbatim edit-payload path) must match the repo-relative
|
|
998
|
+
// stored file paths (issue #296).
|
|
1003
999
|
target = repoRelativeTarget(target, store.publicRoot);
|
|
1004
1000
|
const syms = store.json.loadAll("symbols");
|
|
1005
1001
|
const components = store.json.loadAll("components");
|
|
@@ -1253,6 +1249,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1253
1249
|
}
|
|
1254
1250
|
return null;
|
|
1255
1251
|
};
|
|
1252
|
+
// Exact-id record reads (findings, corrections, imported ADRs, why-by-id): the
|
|
1253
|
+
// same confirmation, gated to shared mode. Null means "re-read; absence is
|
|
1254
|
+
// confirmed or the store is not shared", else the abstention to return instead.
|
|
1255
|
+
const confirmSharedRecord = (subject) => store.mode === "shared" && store.privateDir ? confirmSharedMiss(`${subject} is absent from shared team memory`, subject) : null;
|
|
1256
1256
|
// Policy reads: confirm a local policy (or its referenced proof) miss against
|
|
1257
1257
|
// fresh shared memory before the service reports "not found". Public-only reads
|
|
1258
1258
|
// and non-shared stores never consult the team remote.
|
|
@@ -1368,23 +1368,20 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1368
1368
|
};
|
|
1369
1369
|
const registerTool = server.registerTool.bind(server);
|
|
1370
1370
|
server.registerTool = ((name, config, callback) => hiddenTools.has(name) ? undefined : registerTool(name, config, async (...args) => {
|
|
1371
|
-
// Claude Code CLI never advertises
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
1375
|
-
//
|
|
1376
|
-
//
|
|
1377
|
-
|
|
1378
|
-
// root/store out from under it, so that case is refused rather than risked.
|
|
1379
|
-
const cwdHint = extractCwdHint(args[0]);
|
|
1371
|
+
// The Claude Code CLI never advertises roots/list_changed for an agent `cd` or worktree
|
|
1372
|
+
// entry, so the cached root never moves and writes land where the process was spawned.
|
|
1373
|
+
// Write tools therefore accept an optional `cwd` (see cwdHintField) as a client-agnostic
|
|
1374
|
+
// fallback, resolved fresh on every call rather than cached, re-homing this stdio
|
|
1375
|
+
// process the way a roots notification would. That is only safe when no other request
|
|
1376
|
+
// is in flight, so that case is refused instead of risked.
|
|
1377
|
+
const cwdHint = cwdHintOf(args[0]);
|
|
1380
1378
|
// A pinned root is the whole point of `hunch mcp --root`: a served partition must not
|
|
1381
1379
|
// follow the caller's working directory into some other checkout.
|
|
1382
1380
|
if (cwdHint !== undefined && !pinned) {
|
|
1383
1381
|
const target = canonicalRootPath(findRoot(cwdHint));
|
|
1384
1382
|
if (target !== canonicalRootPath(root)) {
|
|
1385
|
-
if (activeRequests) {
|
|
1386
|
-
return err(`Hunch is
|
|
1387
|
-
`(resolves to ${target}) while another call is in flight. Retry this call once the other one completes.`);
|
|
1383
|
+
if (activeRequests !== 0) {
|
|
1384
|
+
return err(`Hunch is serving ${root} and cannot safely switch to your working directory (${target}) while another call is in flight. Retry once the other call completes.`);
|
|
1388
1385
|
}
|
|
1389
1386
|
try {
|
|
1390
1387
|
setRoot(cwdHint);
|
|
@@ -1508,16 +1505,23 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1508
1505
|
if (as_of && !asOf)
|
|
1509
1506
|
return invalid(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
1510
1507
|
let w = store.why(target, { asOf });
|
|
1508
|
+
// An id-shaped target is an id miss when the store reported one (generated
|
|
1509
|
+
// shape) or, for a hand-written id, when nothing else matched it either.
|
|
1510
|
+
const idMiss = (r) => !r.byId?.record && (r.byId !== undefined || (WHY_RECORD_ID.test(target)
|
|
1511
|
+
&& !r.decisions.length && !r.constraints.length && !r.bugs.length && !r.components.length && !r.symbols.length));
|
|
1511
1512
|
// "No record <id>" is a team-absence claim in shared mode: confirm the local
|
|
1512
1513
|
// miss against a fresh pull first, like hunch_current_decision. A record held
|
|
1513
1514
|
// locally but outside its as_of window is not a team-absence claim: no pull.
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
const abstention =
|
|
1515
|
+
const heldNow = () => !!(asOf && store.why(target).byId?.record);
|
|
1516
|
+
if (idMiss(w) && !heldNow()) {
|
|
1517
|
+
const abstention = confirmSharedRecord(`record ${w.byId?.id ?? target}`);
|
|
1517
1518
|
if (abstention)
|
|
1518
1519
|
return err(abstention);
|
|
1519
1520
|
w = store.why(target, { asOf });
|
|
1520
1521
|
}
|
|
1522
|
+
// A hand-written id held now but not in force at as_of: say so, like a generated id.
|
|
1523
|
+
if (asOf && !w.byId && idMiss(w) && heldNow())
|
|
1524
|
+
w = { ...w, byId: { id: target, record: null, asOf } };
|
|
1521
1525
|
if (w.byId?.record)
|
|
1522
1526
|
return ok(formatWhyRecord(w.byId).join("\n"));
|
|
1523
1527
|
// Highest-signal first, then cap: invariants by severity, decisions by
|
|
@@ -1912,13 +1916,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1912
1916
|
// -- hunch_now (the hot view: recent activity + roadmap) --------------------
|
|
1913
1917
|
// PUBLIC store only, per dec_29eff08c69's jurisdiction rule: an assistant may
|
|
1914
1918
|
// paste this anywhere, so it must be publishable by construction. Union view
|
|
1915
|
-
//
|
|
1916
|
-
// queued commit-repair's liveness is checked against the full store
|
|
1917
|
-
//
|
|
1918
|
-
//
|
|
1919
|
+
// is available only through the local terminal command `hunch now --private`.
|
|
1920
|
+
// Exception: a queued commit-repair's liveness is checked against the full store, so a
|
|
1921
|
+
// private-overlay decision's id and commit shas (never its title) may appear in the
|
|
1922
|
+
// escalation line.
|
|
1919
1923
|
server.registerTool("hunch_now", {
|
|
1920
1924
|
title: "Recent activity + the roadmap (the hot view)",
|
|
1921
|
-
description: "What just happened and what
|
|
1925
|
+
description: "What just happened and what is next, straight from the graph: the last N decisions, the ROADMAP, and any inline human question such as an imported ADR awaiting explicit approve/decline. Call at session start to orient, or before planning. Same data as the wiki's now.md. Public store only — except that a queued commit-repair's liveness is checked against the full store, so a private-overlay decision's answerable repair does not go silently unanswerable; only its id and commit shas ever surface, never its title.",
|
|
1922
1926
|
inputSchema: {
|
|
1923
1927
|
recent_limit: z.number().optional().describe("How many recent decisions to include (default 5)."),
|
|
1924
1928
|
roadmap_limit: z.number().optional().describe("How many roadmap entries to include (default 2); the rest are counted."),
|
|
@@ -1951,18 +1955,15 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1951
1955
|
L.push("", ws);
|
|
1952
1956
|
const escalations = pendingEscalations(store.advisoryRecs("decisions"));
|
|
1953
1957
|
escalations.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
|
|
1954
|
-
//
|
|
1955
|
-
//
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
const actionableNow = actionableEscalations(escalations);
|
|
1964
|
-
if (actionableNow.length) {
|
|
1965
|
-
L.push("", `⚖ ${actionableNow.length} decision(s) need the human's call — ASK inline (never queue): ${actionableNow.map((e) => e.question).join(" · ")}`);
|
|
1958
|
+
// Liveness is checked against the FULL store (the title stays scoped to advisory
|
|
1959
|
+
// records), so an overlay decision's repair does not go silent.
|
|
1960
|
+
const repairQueue = readActivePendingRepairs(root);
|
|
1961
|
+
escalations.push(...commitRepairEscalations(repairQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, repairQueue)));
|
|
1962
|
+
// A duplicate-id commit-repair follower whose own answer is "act on a different
|
|
1963
|
+
// entry first" is not a question to raise (#61).
|
|
1964
|
+
const actionable = actionableEscalations(escalations);
|
|
1965
|
+
if (actionable.length) {
|
|
1966
|
+
L.push("", `⚖ ${actionable.length} decision(s) need the human's call — ASK inline (never queue): ${actionable.map((e) => e.question).join(" · ")}`);
|
|
1966
1967
|
}
|
|
1967
1968
|
return ok(L.join("\n"));
|
|
1968
1969
|
});
|
|
@@ -2028,24 +2029,20 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2028
2029
|
// Premise decay: a live decision whose recorded reason died. Question-framed
|
|
2029
2030
|
// like every entry here — authority never changes until the human answers.
|
|
2030
2031
|
items.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
|
|
2031
|
-
//
|
|
2032
|
-
const
|
|
2033
|
-
items.push(...commitRepairEscalations(
|
|
2032
|
+
// Live set is the full store, for the reason given in the sibling hunch_now handler.
|
|
2033
|
+
const repairQueue = readActivePendingRepairs(root);
|
|
2034
|
+
items.push(...commitRepairEscalations(repairQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, repairQueue)));
|
|
2034
2035
|
try {
|
|
2035
2036
|
items.push(...policyEscalations(new ConstitutionService(store, root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
|
|
2036
2037
|
}
|
|
2037
2038
|
catch { /* constitution unavailable — memory escalations still surface */ }
|
|
2038
2039
|
if (!items.length)
|
|
2039
2040
|
return ok("✓ Nothing needs a human decision — memory is auto-trusted and self-consistent.");
|
|
2040
|
-
// Only
|
|
2041
|
-
//
|
|
2042
|
-
// a different entry first" still surfaces below for transparency, but
|
|
2043
|
-
// isn't itself something to raise as a decision (#61).
|
|
2041
|
+
// Only actionable entries are questions to raise; non-actionable followers still
|
|
2042
|
+
// appear below for transparency (#61).
|
|
2044
2043
|
const L = [escalationHeadline(items, "mcp"), ""];
|
|
2045
2044
|
for (const e of items) {
|
|
2046
|
-
//
|
|
2047
|
-
// itself something to raise — an entry whose own answer is "act on a
|
|
2048
|
-
// different entry first" reads as informational, not as a question.
|
|
2045
|
+
// Non-actionable entries are marked distinctly so they read as informational, not as a question.
|
|
2049
2046
|
L.push(`${e.actionable === false ? "·" : "⚖"} ${e.question}`);
|
|
2050
2047
|
L.push(` ${e.detail}`);
|
|
2051
2048
|
L.push(` → ${e.resolution}`, "");
|
|
@@ -2066,7 +2063,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2066
2063
|
},
|
|
2067
2064
|
}, async ({ decision_id, expected_source_hash, expected_review_hash, disposition, reviewed_by }) => {
|
|
2068
2065
|
try {
|
|
2069
|
-
const
|
|
2066
|
+
const find = () => store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
|
|
2067
|
+
let decision = find();
|
|
2068
|
+
if (!decision) {
|
|
2069
|
+
const abstention = confirmSharedRecord(`imported ADR ${decision_id}`);
|
|
2070
|
+
if (abstention)
|
|
2071
|
+
return err(abstention);
|
|
2072
|
+
decision = find();
|
|
2073
|
+
}
|
|
2070
2074
|
if (!decision)
|
|
2071
2075
|
return invalid(`Imported ADR ${decision_id} is not present in the current advisory memory home.`);
|
|
2072
2076
|
// Approval grants human authority, so the agent's report that the human approved
|
|
@@ -2456,7 +2460,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2456
2460
|
const where = decision.private
|
|
2457
2461
|
? ` [PRIVATE overlay — not committed to this repo]${flushed}`
|
|
2458
2462
|
: home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
|
|
2459
|
-
const dest = destinationNote(
|
|
2463
|
+
const dest = destinationNote(destinationRoot(home, store, root));
|
|
2460
2464
|
// The chain (nuryel.state/1): a `shipped` receipt in an organization drawer rests on
|
|
2461
2465
|
// this decision by id + the hash ON FILE + this repository's partition. Hand the ref
|
|
2462
2466
|
// over now so the agent never rests on a pre-store hash or re-derives the scope.
|
|
@@ -2567,7 +2571,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2567
2571
|
const tierNote = `
|
|
2568
2572
|
|
|
2569
2573
|
⚠ ${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 })}`;
|
|
2570
|
-
const dest = destinationNote(
|
|
2574
|
+
const dest = destinationNote(destinationRoot(home, store, root));
|
|
2571
2575
|
return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where}${dest} It now ${enforce}.${reviewNote}${tierNote}`);
|
|
2572
2576
|
}
|
|
2573
2577
|
catch (e) {
|
|
@@ -2648,8 +2652,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2648
2652
|
? `\n\n△ violates_constraint ${rec.violates_constraint} resolves to no known constraint — if the rule isn't recorded yet, hunch_record_correction it and re-record this finding with the real id.`
|
|
2649
2653
|
: "";
|
|
2650
2654
|
const noEvidence = rec.evidence.length ? "" : "\n\n△ No evidence attached — a finding without the query/output that produced it is an opinion. Re-record with evidence when you have it.";
|
|
2651
|
-
const dest = destinationNote(
|
|
2652
|
-
|
|
2655
|
+
const dest = destinationNote(destinationRoot(home, store, root));
|
|
2656
|
+
const grounds = [...rec.affected_files, ...rec.affected_symbols].join(", ") || "(nothing — add affected_files/symbols so it surfaces at edit time)";
|
|
2657
|
+
return ok(`${existing ? "Updated" : "Recorded"} finding ${id}: "${rec.title}" (${rec.triage}/${rec.severity}, observed ${rec.observed_at.slice(0, 10)}).${where}${dest} It now grounds edits to: ${grounds}.${danglingCon}${noEvidence}`);
|
|
2653
2658
|
}
|
|
2654
2659
|
catch (e) {
|
|
2655
2660
|
return err(`Failed to record finding: ${e.message}`);
|
|
@@ -2681,7 +2686,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2681
2686
|
});
|
|
2682
2687
|
server.registerTool("nuryel_read", {
|
|
2683
2688
|
title: "nuryel.state/1 read — the system-of-record answer for a subject",
|
|
2684
|
-
description: "Read organizational state under a delivery receipt. Pass the principal (id, kind, grants) and the scope; optionally a subject (an entity id, a decision topic, an external `object_type:object_key`) to get state_of_record — what is current, in force, done, what it depends on and what invalidates it — plus a task phrase for the ranked delivery envelope. Scopes the principal is not granted are named in denied_scopes, never silently dropped. To read observations beyond the default 64, use observed_page:{} with one scope and a subject, then pass state_of_record.observed_page.next_cursor as observed_page.cursor until null. A conflict means the observations changed: restart from the first page. Never claim complete coverage while a next cursor remains.",
|
|
2689
|
+
description: "Read organizational state under a delivery receipt. Pass the principal (id, kind, grants) and the scope; optionally a subject (an entity id, a decision topic, an external `object_type:object_key`) to get state_of_record — what is current, in force, done, what it depends on and what invalidates it — plus a task phrase for the ranked delivery envelope. Scopes the principal is not granted are named in denied_scopes, never silently dropped. To read observations beyond the default 64, use observed_page:{} with one scope and a subject, then pass state_of_record.observed_page.next_cursor as observed_page.cursor until null. A conflict means the observations changed: restart from the first page. Never claim complete coverage while a next cursor remains. facets filters state_of_record and conventions only; the delivery envelope is ranked independently and keeps the constraints in force.",
|
|
2685
2690
|
inputSchema: ReadRequestSchema.omit({ schema: true }).shape,
|
|
2686
2691
|
outputSchema: ReadResponseSchema.shape,
|
|
2687
2692
|
}, async (input) => {
|
|
@@ -2745,7 +2750,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2745
2750
|
});
|
|
2746
2751
|
server.registerTool("nuryel_write", {
|
|
2747
2752
|
title: "nuryel.state/1 write — provenance + idempotency in, durability out",
|
|
2748
|
-
description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version. With capability nuryel.field-provenance/1, a derived record may carry field_provenance: [{selector:{kind:json_pointer,path:/field} or {kind:text,start:0,end:10}, value_hash:stateHash(selected scalar or text), dependency_hashes:[stateHash(existing dependency)]}]. Text offsets count Unicode code points, end exclusive. Citations are writer-supplied traceability, not verified support; negotiate support in every shared reader before writing them. With nuryel.record-visibility/1, records may carry visibility:{owner:principal-id,readers:[ids],writers:[ids]}; the owner is implicit in both lists, and writers must be readers. Owner-only audience changes require expected_version, including supersession. Restricted records require a dedicated partition; local stdio principal assertions assume trusted callers.",
|
|
2753
|
+
description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version. With capability nuryel.field-provenance/1, a derived record may carry field_provenance: [{selector:{kind:json_pointer,path:/field} or {kind:text,start:0,end:10}, value_hash:stateHash(selected scalar or text), dependency_hashes:[stateHash(existing dependency)]}]. Text offsets count Unicode code points, end exclusive. Citations are writer-supplied traceability, not verified support; negotiate support in every shared reader before writing them. With nuryel.record-visibility/1, records may carry visibility:{owner:principal-id,readers:[ids],writers:[ids]}; the owner is implicit in both lists, and writers must be readers. Owner-only audience changes require expected_version, including supersession. Restricted records require a dedicated partition; local stdio principal assertions assume trusted callers. expected_version is compare-and-swap against the incumbent, or with supersedes against the predecessor (its record hash or ledger seq); for a record not on file pass null, or 0 to assert the id was never written — any other value is refused as a conflict.",
|
|
2749
2754
|
inputSchema: { ...WriteRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
|
|
2750
2755
|
outputSchema: WriteResultSchema.shape,
|
|
2751
2756
|
}, async ({ cwd: _cwd, ...input }) => {
|
|
@@ -2850,7 +2855,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2850
2855
|
const concerns = (f) => [...f.affected_files, ...f.affected_symbols].join(", ") || "(unscoped)";
|
|
2851
2856
|
const links = (f) => [f.violates_constraint ? `violates ${f.violates_constraint}` : "", f.method ? `re-verify via ${f.method}` : "", f.resolved_commit ? `fixed in ${f.resolved_commit.slice(0, 9)}` : ""].filter(Boolean).join("; ");
|
|
2852
2857
|
if (id) {
|
|
2853
|
-
const
|
|
2858
|
+
const find = () => store.recs("findings").find((r) => r.id === id);
|
|
2859
|
+
let f = find();
|
|
2860
|
+
if (!f) {
|
|
2861
|
+
const abstention = confirmSharedRecord(`finding ${id}`);
|
|
2862
|
+
if (abstention)
|
|
2863
|
+
return err(abstention);
|
|
2864
|
+
f = find();
|
|
2865
|
+
}
|
|
2854
2866
|
if (!f)
|
|
2855
2867
|
return ok(`No finding "${id}".`);
|
|
2856
2868
|
return ok(`[${f.triage}/${f.severity}] ${f.title} (${f.id}, observed ${f.observed_at.slice(0, 10)})\n${f.observation}\nconcerns: ${concerns(f)}${links(f) ? `\n${links(f)}` : ""}`);
|
|
@@ -2883,6 +2895,16 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2883
2895
|
try {
|
|
2884
2896
|
if (public_only && private_only)
|
|
2885
2897
|
return invalid("Choose only one of public_only or private_only.");
|
|
2898
|
+
// A correction missing from every home this call may read is confirmed against
|
|
2899
|
+
// fresh shared memory before the upgrade reports it "not found". Public-only
|
|
2900
|
+
// reads never consult the team remote.
|
|
2901
|
+
const heldCorrection = () => !!(store.getPrivateRec("constraints", constraint_id)
|
|
2902
|
+
|| (!private_only && store.json.get("constraints", constraint_id)));
|
|
2903
|
+
if (!public_only && !heldCorrection()) {
|
|
2904
|
+
const abstention = confirmSharedRecord(`correction ${constraint_id}`);
|
|
2905
|
+
if (abstention)
|
|
2906
|
+
return err(abstention);
|
|
2907
|
+
}
|
|
2886
2908
|
// Resolve the correction's exact home before any writes. Overlay-first is
|
|
2887
2909
|
// the same selection contract as ConstitutionService.upgradeCorrection;
|
|
2888
2910
|
// deriving this later from a policy id is unsafe when legacy public and
|
|
@@ -2915,7 +2937,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2915
2937
|
if (artifactHome === "private") {
|
|
2916
2938
|
flushMemoryHome(store, hunchPaths(root).hunch, "private", `hunch: prove correction ${constraint_id}`, startupTeamRoute ?? undefined);
|
|
2917
2939
|
}
|
|
2918
|
-
const destRoot =
|
|
2940
|
+
const destRoot = destinationRoot(artifactHome, store, root);
|
|
2919
2941
|
const destination = { root: destRoot, branch: currentBranch(destRoot) };
|
|
2920
2942
|
if (include_artifacts)
|
|
2921
2943
|
return ok(JSON.stringify({ ...upgrade, destination }, null, 2));
|
|
@@ -3104,7 +3126,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
3104
3126
|
if (!home)
|
|
3105
3127
|
throw new Error(`policy ${policy_id} has no exact storage home`);
|
|
3106
3128
|
flushMemoryHome(store, hunchPaths(root).hunch, home, `hunch: plan policy ${policy_id}`, startupTeamRoute ?? undefined);
|
|
3107
|
-
const destRoot =
|
|
3129
|
+
const destRoot = destinationRoot(home, store, root);
|
|
3108
3130
|
return ok(JSON.stringify({ ...plan, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
|
|
3109
3131
|
}
|
|
3110
3132
|
catch (e) {
|
|
@@ -3402,7 +3424,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
3402
3424
|
dependencyTimeoutMs: dependency_timeout_ms ?? 300_000,
|
|
3403
3425
|
});
|
|
3404
3426
|
flushMemoryHome(store, hunchPaths(root).hunch, "private", "hunch: materialize G2 behavior policies", startupTeamRoute ?? undefined);
|
|
3405
|
-
const destRoot =
|
|
3427
|
+
const destRoot = destinationRoot("private", store, root);
|
|
3406
3428
|
return ok(JSON.stringify({ ...materialized, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
|
|
3407
3429
|
}
|
|
3408
3430
|
catch (e) {
|
|
@@ -3479,6 +3501,10 @@ export async function startServer(cwd = process.cwd(), options = {}) {
|
|
|
3479
3501
|
wireClientRoots(control, fallback);
|
|
3480
3502
|
const transport = new StdioServerTransport();
|
|
3481
3503
|
await control.server.connect(transport);
|
|
3504
|
+
// Exit through process.exit on a termination signal (same conventional codes as
|
|
3505
|
+
// a signal death) so NODE_V8_COVERAGE is flushed for `hunch verify --refresh-map`.
|
|
3506
|
+
process.once("SIGTERM", () => process.exit(143));
|
|
3507
|
+
process.once("SIGINT", () => process.exit(130));
|
|
3482
3508
|
console.error(control.pinned
|
|
3483
3509
|
? `[hunch-mcp] serving Hunch over stdio (pinned root ${control.getRoot()}; client roots ignored)`
|
|
3484
3510
|
: `[hunch-mcp] serving Hunch over stdio (spawn root ${control.getRoot()}; resolving client roots…)`);
|
package/dist/serve/app.js
CHANGED
|
@@ -35,6 +35,8 @@ import { operatorHtml, operatorCss, operatorJs } from "./operator.js";
|
|
|
35
35
|
import { MCP_PATH, handleMcpRequest } from "./mcpHttp.js";
|
|
36
36
|
export const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
37
37
|
const POST_ROUTES = new Map([["/nuryel/v1/read", "read"], ["/nuryel/v1/write", "write"], ["/nuryel/v1/capture", "capture"], ["/nuryel/v1/capture-batch", "capture-batch"], ["/nuryel/v1/subscribe", "subscribe"], ["/nuryel/v1/records", "records"]]);
|
|
38
|
+
/** GET-only routes: any other method is 405 with `Allow: GET`, not a POST-table 404. */
|
|
39
|
+
const GET_ROUTES = new Set(["/nuryel/v1/health", "/nuryel/v1/capabilities"]);
|
|
38
40
|
export const PROBLEM_TYPE = "https://www.hunchmemory.com/problems/nuryel.state/1/";
|
|
39
41
|
export class HttpProblem extends Error {
|
|
40
42
|
status;
|
|
@@ -272,6 +274,10 @@ export function createServeApp(config, opts = {}) {
|
|
|
272
274
|
return send(res, 200, { ...liveness, partitions: activeConfig.partitions.map((p) => scopePath(p.scope)) });
|
|
273
275
|
}
|
|
274
276
|
const principal = await authenticate(req, res, url, activeConfig);
|
|
277
|
+
if (GET_ROUTES.has(url.pathname) && req.method !== "GET") {
|
|
278
|
+
res.setHeader("allow", "GET");
|
|
279
|
+
throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
|
|
280
|
+
}
|
|
275
281
|
if (url.pathname === "/nuryel/v1/capabilities" && req.method === "GET") {
|
|
276
282
|
const scope = parseScopeParam(url.searchParams.get("scope"));
|
|
277
283
|
const { status, payload } = await dispatch("capabilities", principal, scope ? { scope } : {}, activeConfig);
|
|
@@ -286,8 +292,10 @@ export function createServeApp(config, opts = {}) {
|
|
|
286
292
|
const message = await readBody(req);
|
|
287
293
|
return await handleMcpRequest(req, res, message, (route, body) => dispatch(route, principal, body, activeConfig), (error) => problemBody(problemOf(error)), version);
|
|
288
294
|
}
|
|
289
|
-
if (req.method !== "POST")
|
|
295
|
+
if (req.method !== "POST") {
|
|
296
|
+
res.setHeader("allow", "POST");
|
|
290
297
|
throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
|
|
298
|
+
}
|
|
291
299
|
const route = POST_ROUTES.get(url.pathname);
|
|
292
300
|
if (!route)
|
|
293
301
|
throw problem(404, "not-found", `${url.pathname} is not a nuryel.state/1 route`);
|
package/dist/serve/config.js
CHANGED
|
@@ -114,6 +114,11 @@ export function initServeConfig(opts) {
|
|
|
114
114
|
if (!publicOrigin)
|
|
115
115
|
throw new Error("key-bound principals require an HTTPS public origin");
|
|
116
116
|
}
|
|
117
|
+
// Refuse before writing anything: a grant must name a partition this config will serve.
|
|
118
|
+
const served = new Set([...(existing?.partitions ?? []).map((p) => scopePath(p.scope)), scopePath(opts.scope)]);
|
|
119
|
+
for (const g of opts.principal?.grants ?? [])
|
|
120
|
+
if (!served.has(scopePath(g)))
|
|
121
|
+
throw new Error(`cannot grant ${scopePath(g)}: this server does not serve it`);
|
|
117
122
|
const root = resolve(opts.root);
|
|
118
123
|
const hunchDir = resolve(root, ".hunch");
|
|
119
124
|
mkdirSync(hunchDir, { recursive: true });
|
|
@@ -10,10 +10,12 @@ export declare const ACQUIRE_TIMEOUT_MS = 10000;
|
|
|
10
10
|
* claimed section is a few syscalls, so anything this old means its claimer
|
|
11
11
|
* crashed inside it. */
|
|
12
12
|
export declare const CLAIM_STALE_MS = 10000;
|
|
13
|
+
/** `nonce` is optional on READ only: this lock always writes one, but a legacy or
|
|
14
|
+
* foreign writer's record may lack it (issue #491). */
|
|
13
15
|
export interface LockOwner {
|
|
14
16
|
pid: number;
|
|
15
17
|
host: string;
|
|
16
|
-
nonce
|
|
18
|
+
nonce?: string;
|
|
17
19
|
at: string;
|
|
18
20
|
start?: string;
|
|
19
21
|
}
|
package/dist/serve/writelock.js
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* equality and never by clock arithmetic (see core/procstart.ts). Where no token can
|
|
14
14
|
* compare them (macOS, Windows, legacy records) a stale-looking lock whose live pid
|
|
15
15
|
* belongs to a process CREATED after the lock's recorded `at` is taken over too: that
|
|
16
|
-
* process cannot be the one that took it (issue #287).
|
|
16
|
+
* process cannot be the one that took it (issue #287). That creation-time check
|
|
17
|
+
* runs on macOS and Windows only; on linux `processStartedAt` returns null, so a
|
|
18
|
+
* tokenless live pid there is never taken over. Removing a stale
|
|
17
19
|
* lock is itself serialized behind an exclusive `.reclaim` claim directory, because
|
|
18
20
|
* judging and removing are two steps and two contenders can otherwise both "win"
|
|
19
21
|
* (see `takeOver`).
|
|
@@ -56,12 +58,19 @@ export function writeLockPath(hunchDir) {
|
|
|
56
58
|
function readOwner(path) {
|
|
57
59
|
try {
|
|
58
60
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
59
|
-
|
|
61
|
+
// A missing nonce is not unreadable metadata: the pid still names a
|
|
62
|
+
// same-host owner, so it gets the live-pid judgement `.rmw-lock` gives it,
|
|
63
|
+
// never the age fallback that would steal a live writer's lock (#491).
|
|
64
|
+
// The pid check mirrors `.rmw-lock`'s: `kill(0|-n, 0)` signals a process
|
|
65
|
+
// group and always "succeeds" (a live wedge forever), and a fractional or
|
|
66
|
+
// huge pid throws (stolen even when fresh) — so such a record is unreadable
|
|
67
|
+
// and age decides.
|
|
68
|
+
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid < 1)
|
|
60
69
|
return undefined;
|
|
61
70
|
return {
|
|
62
71
|
pid: parsed.pid,
|
|
63
72
|
host: typeof parsed.host === "string" ? parsed.host : "",
|
|
64
|
-
nonce: parsed.nonce,
|
|
73
|
+
...(typeof parsed.nonce === "string" ? { nonce: parsed.nonce } : {}),
|
|
65
74
|
at: typeof parsed.at === "string" ? parsed.at : "",
|
|
66
75
|
...(typeof parsed.start === "string" ? { start: parsed.start } : {}),
|
|
67
76
|
};
|
|
@@ -268,6 +277,11 @@ export async function withWriteLock(hunchDir, fn, opts = {}) {
|
|
|
268
277
|
// from, the file now protects another writer. The nonce is dropped in a
|
|
269
278
|
// `finally` of its own, so a throw from the conditional remove can never
|
|
270
279
|
// leave us vouching for a lock we no longer hold.
|
|
280
|
+
//
|
|
281
|
+
// DIFFERS from `.rmw-lock` on release: a readable record WITHOUT a nonce is
|
|
282
|
+
// left in place here (it is not ours — a legacy or foreign writer replaced
|
|
283
|
+
// our file), where `.rmw-lock` removes it. Only an unreadable record is
|
|
284
|
+
// removed unconditionally.
|
|
271
285
|
try {
|
|
272
286
|
const current = readOwner(path);
|
|
273
287
|
if (!current || current.nonce === nonce)
|
|
@@ -403,7 +403,8 @@ export declare class HunchStore {
|
|
|
403
403
|
private whyRecord;
|
|
404
404
|
/** Transitive blast radius: every symbol/component that (in)directly depends on
|
|
405
405
|
* `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
|
|
406
|
-
* walk edges BACKWARD (edges.to = current) following call/dep/import/contains
|
|
406
|
+
* walk edges BACKWARD (edges.to = current) following call/dep/import/contains,
|
|
407
|
+
* plus `references` — the only edge YAML/Helm/Kustomize emit (issue #486). */
|
|
407
408
|
getDependents(id: string, maxDepth?: number): Array<{
|
|
408
409
|
id: string;
|
|
409
410
|
depth: number;
|