@davesheffer/hunch 1.45.2 → 1.46.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.
Files changed (53) hide show
  1. package/dist/cli/index.js +139 -32
  2. package/dist/cli/invocation.d.ts +14 -0
  3. package/dist/cli/invocation.js +23 -0
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/experiment.d.ts +1 -1
  7. package/dist/core/agenthook.js +26 -6
  8. package/dist/core/boundedSpawn.d.ts +57 -0
  9. package/dist/core/boundedSpawn.js +321 -0
  10. package/dist/core/config.d.ts +6 -0
  11. package/dist/core/config.js +1 -0
  12. package/dist/core/delivery.d.ts +6 -1
  13. package/dist/core/delivery.js +71 -5
  14. package/dist/core/migrate.d.ts +10 -1
  15. package/dist/core/migrate.js +17 -6
  16. package/dist/core/projectDna.js +11 -1
  17. package/dist/core/relativeImports.js +6 -2
  18. package/dist/core/taskSelection.js +48 -6
  19. package/dist/core/testSelection.d.ts +122 -0
  20. package/dist/core/testSelection.js +588 -0
  21. package/dist/extractors/git.js +32 -31
  22. package/dist/extractors/indexer.js +42 -6
  23. package/dist/extractors/languages.d.ts +9 -0
  24. package/dist/extractors/languages.js +28 -0
  25. package/dist/extractors/parse.d.ts +20 -0
  26. package/dist/extractors/parse.js +81 -5
  27. package/dist/extractors/workspaces.js +3 -1
  28. package/dist/integrations/health.d.ts +6 -0
  29. package/dist/integrations/health.js +33 -12
  30. package/dist/integrations/hooks.d.ts +4 -2
  31. package/dist/integrations/hooks.js +12 -2
  32. package/dist/integrations/mergeDriver.js +5 -3
  33. package/dist/integrations/optout.d.ts +3 -0
  34. package/dist/integrations/optout.js +17 -0
  35. package/dist/integrations/providers.d.ts +6 -2
  36. package/dist/integrations/providers.js +19 -2
  37. package/dist/integrations/team.js +4 -2
  38. package/dist/integrations/uninstall.d.ts +10 -4
  39. package/dist/integrations/uninstall.js +22 -18
  40. package/dist/mcp/server.js +47 -8
  41. package/dist/serve/app.js +9 -1
  42. package/dist/serve/config.js +5 -0
  43. package/dist/serve/writelock.d.ts +3 -1
  44. package/dist/serve/writelock.js +17 -3
  45. package/dist/store/hunchStore.d.ts +2 -1
  46. package/dist/store/hunchStore.js +8 -5
  47. package/dist/store/jsonStore.d.ts +13 -3
  48. package/dist/store/jsonStore.js +37 -13
  49. package/dist/store/stateBinding.js +17 -4
  50. package/dist/synthesis/provider.js +26 -78
  51. package/dist/synthesis/synthesize.js +1 -1
  52. package/package.json +2 -2
  53. package/server.json +2 -2
@@ -19,6 +19,7 @@ import { join, isAbsolute, dirname, basename, relative, resolve } from "node:pat
19
19
  import { homedir } from "node:os";
20
20
  import { hooksDir, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
21
21
  import { initiatorChildEnv } from "../synthesis/initiator.js";
22
+ import { isOptedOut, OPT_OUT_FILE } from "./optout.js";
22
23
  import { HUNCH_NPX_PACKAGE_SPEC, HUNCH_PACKAGE_NAME } from "../core/version.js";
23
24
  import { eolLengthAt, normalizeEol } from "../core/eol.js";
24
25
  const MARK = "# >>> hunch post-commit >>>";
@@ -617,6 +618,11 @@ function sharedInvocationFor(root, mark, invocation, own) {
617
618
  * or the block would sit after an exec/exit, nothing is written and the result
618
619
  * carries the snippet to add by hand (issue #311). */
619
620
  function installManagedBlock(root, hookName, mark, end, build, invocation) {
621
+ // A linked worktree whose branch predates the opt-out marker shares the main
622
+ // checkout's hooks dir: never re-hook a repository `hunch uninstall` opted out (#488).
623
+ const main = mainWorktreeRoot(root);
624
+ if (isOptedOut(main))
625
+ return { path: resolve(root, hooksDir(root), hookName), action: "opted-out", reason: `${join(main, OPT_OUT_FILE)} opts this repository out` };
620
626
  const t = resolveHookTarget(root, hookName, mark);
621
627
  if (t.manager !== "none") {
622
628
  const info = blockInfo(t, mark, root);
@@ -828,8 +834,8 @@ function repairProvenanceMergeBlock(invocation) {
828
834
  * (the file itself is new) outranks "appended"/"updated" (an existing file
829
835
  * changed), which outrank "unchanged". Non-writing results are handled before
830
836
  * ranking (they must never be masked by a sibling's success). */
831
- const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "kept-shared": 1, "managed-elsewhere": 0, unreachable: 0 };
832
- const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable";
837
+ const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "kept-shared": 1, "managed-elsewhere": 0, unreachable: 0, "opted-out": 0 };
838
+ const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable" && h.action !== "opted-out";
833
839
  /** Install a post-merge hook carrying TWO independently-managed blocks:
834
840
  * re-sync the committed grounding docs when a merge brought memory in behind
835
841
  * them (fnd_c402046ac7, HUNCH_SYNC-guarded, foreground — it rewrites five
@@ -850,6 +856,8 @@ export function installPostMergeHook(root, invocation) {
850
856
  // passed invocation unchanged — see selfBuildInvocation.
851
857
  const grounding = installManagedBlock(root, "post-merge", GROUNDING_MERGE_MARK, GROUNDING_MERGE_END, (inv, ctx) => groundingMergeBlock(ctx.portable ? inv : selfBuildInvocation(root, inv)), invocation);
852
858
  const repair = installManagedBlock(root, "post-merge", REPAIR_MERGE_MARK, REPAIR_MERGE_END, repairProvenanceMergeBlock, invocation);
859
+ if (grounding.action === "opted-out")
860
+ return grounding;
853
861
  if (!writes(grounding) && !writes(repair)) {
854
862
  return { ...grounding, snippet: `${grounding.snippet}\n${stripComment(repair.snippet ?? "", grounding.manager)}` };
855
863
  }
@@ -1025,6 +1033,8 @@ export function formatHookInstall(root, label, h, detail = "") {
1025
1033
  const how = h.keptFlags?.length ? KEPT_HOW : "";
1026
1034
  return [` ✓ ${label} kept — the shared hook keeps running ${h.sharedInvocation}${flags} (not re-pointed at this worktree)${how}`];
1027
1035
  }
1036
+ if (h.action === "opted-out")
1037
+ return [` - ${label} skipped — ${h.reason}`];
1028
1038
  if (writes(h)) {
1029
1039
  // A union write (issue #316). Once an option was KEPT, `detail` — which
1030
1040
  // describes only what this run asked for — is no longer the truth about the
@@ -14,6 +14,7 @@ import { readFileSync, existsSync } from "node:fs";
14
14
  import { writeFileAtomic } from "../core/io.js";
15
15
  import { assertSafeTopLevelConfigFile } from "./gitignore.js";
16
16
  import { GROUNDING_DOC_PATHS } from "./providers.js";
17
+ import { mergeDriverShell } from "../cli/invocation.js";
17
18
  // Route the .hunch JSON records through the structured driver — but NOT the
18
19
  // manifest (an id-less `{schema_version}` object the driver can't merge by id; a
19
20
  // normal text merge with conflict markers is the right behavior for it).
@@ -58,9 +59,10 @@ export function installMergeDriver(root, invShell) {
58
59
  }
59
60
  // 2. Local git config — the driver definitions are per-clone (they reference
60
61
  // this machine's node + cli path), so they are NOT committed; teammates
61
- // re-run init.
62
- const driver = `${invShell} merge-driver "%O" "%A" "%B" "%P"`;
63
- const groundingDriver = `${invShell} merge-driver-grounding "%O" "%A" "%B" "%P"`;
62
+ // re-run init. Dev mode swaps `npx tsx` for Hunch's own loader (#492).
63
+ const shell = mergeDriverShell(invShell);
64
+ const driver = `${shell} merge-driver "%O" "%A" "%B" "%P"`;
65
+ const groundingDriver = `${shell} merge-driver-grounding "%O" "%A" "%B" "%P"`;
64
66
  const env = targetRepositoryEnv();
65
67
  try {
66
68
  execFileSync("git", ["config", "merge.hunch.name", "hunch structured JSON merge"], { cwd: root, env });
@@ -0,0 +1,3 @@
1
+ /** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
2
+ export declare const OPT_OUT_FILE = ".hunch-removed";
3
+ export declare function isOptedOut(root: string): boolean;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The opt-out marker `hunch uninstall` leaves (#449), apart from uninstall.ts so the
3
+ * hook installers can honor it without an import cycle (uninstall.ts imports hooks.ts).
4
+ */
5
+ import { lstatSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ /** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
8
+ export const OPT_OUT_FILE = ".hunch-removed";
9
+ export function isOptedOut(root) {
10
+ try {
11
+ return lstatSync(join(root, OPT_OUT_FILE)).isFile();
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ //# sourceMappingURL=optout.js.map
@@ -73,8 +73,12 @@ export declare function writeCodexHooks(root: string, inv: Invocation): string;
73
73
  export declare function writeVscodeHooks(root: string, inv: Invocation): string;
74
74
  /** Windsurf's documented workspace hooks. It only supports deterministic
75
75
  * pre-hook blocking via exit code 2, so Hunch uses rules + MCP for context and
76
- * reserves the hook for strict edit protection and pipeline observation. */
77
- export declare function writeWindsurfHooks(root: string, inv: Invocation): string;
76
+ * reserves the hook for strict edit protection and pipeline observation.
77
+ * Devin Desktop (formerly Windsurf) reads .devin/hooks.json and ignores the
78
+ * legacy .windsurf/hooks.json whenever the former defines any hook, so Hunch
79
+ * merges into both (docs.devin.ai/desktop/cascade/hooks, #494). Both files are
80
+ * parsed before either is written: an unparseable one refuses the whole write. */
81
+ export declare function writeWindsurfHooks(root: string, inv: Invocation): string[];
78
82
  /** Antigravity keeps hook groups at the top level (not under `hooks`). Hunch
79
83
  * owns only the `hunch` group and replaces its own old entries idempotently. */
80
84
  export declare function writeAntigravityHooks(root: string, inv: Invocation): string;
@@ -419,9 +419,26 @@ function publishedPinEvents(file) {
419
419
  }
420
420
  /** Windsurf's documented workspace hooks. It only supports deterministic
421
421
  * pre-hook blocking via exit code 2, so Hunch uses rules + MCP for context and
422
- * reserves the hook for strict edit protection and pipeline observation. */
422
+ * reserves the hook for strict edit protection and pipeline observation.
423
+ * Devin Desktop (formerly Windsurf) reads .devin/hooks.json and ignores the
424
+ * legacy .windsurf/hooks.json whenever the former defines any hook, so Hunch
425
+ * merges into both (docs.devin.ai/desktop/cascade/hooks, #494). Both files are
426
+ * parsed before either is written: an unparseable one refuses the whole write. */
423
427
  export function writeWindsurfHooks(root, inv) {
424
- const file = join(root, ".windsurf", "hooks.json");
428
+ const files = [join(root, ".windsurf", "hooks.json"), join(root, ".devin", "hooks.json")];
429
+ // Same shape checks writeHookConfig applies at write time, so neither file is
430
+ // written when the other would refuse.
431
+ for (const file of files) {
432
+ const hooks = objectField(readJsonObj(file), "hooks", file);
433
+ for (const event of WINDSURF_EVENTS) {
434
+ if (hooks[event] !== undefined && !Array.isArray(hooks[event])) {
435
+ throw new Error(`refusing to edit ${file}: hooks.${event} must be an array when present; fix it, then re-run.`);
436
+ }
437
+ }
438
+ }
439
+ return files.map((file) => writeWindsurfHookFile(file, inv));
440
+ }
441
+ function writeWindsurfHookFile(file, inv) {
425
442
  const published = hookCommand(publishedMcpInvocation(), "windsurf");
426
443
  let command = hookCommand(inv, "windsurf");
427
444
  // A source-checkout run (tsx / local dist) must never replace a tracked
@@ -8,6 +8,7 @@
8
8
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync } from "node:fs";
9
9
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
10
10
  import { spawnSync } from "node:child_process";
11
+ import { boundedSpawnSync } from "../core/boundedSpawn.js";
11
12
  import { writeFileAtomic } from "../core/io.js";
12
13
  import { machineFile } from "../core/machine.js";
13
14
  import { hunchTreeAttributesAreSafe, safeOverlayGitTreeListing, safeOverlayTree } from "../core/overlaySafety.js";
@@ -654,13 +655,14 @@ export function cloneValidatedTeamOverlay(sharedRepo, sharedRepoCwd, destination
654
655
  }
655
656
  const cloneEnv = boundedTeamGitEnv();
656
657
  stage = "clone";
657
- const cloned = spawnSync("git", [
658
+ // Bounded so a timeout kills git's whole tree, not just the root (#498).
659
+ const cloned = boundedSpawnSync("git", [
658
660
  "-c", "protocol.ext.allow=never",
659
661
  "-c", `core.hooksPath=${emptyHooks}`,
660
662
  "clone", "--no-checkout", `--template=${emptyTemplate}`,
661
663
  "--", repo, stagedDest,
662
664
  ], {
663
- stdio: "ignore",
665
+ stdout: "ignore",
664
666
  env: cloneEnv,
665
667
  timeout: timeoutMs,
666
668
  });
@@ -1,6 +1,5 @@
1
- /** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
2
- export declare const OPT_OUT_FILE = ".hunch-removed";
3
- export declare function isOptedOut(root: string): boolean;
1
+ import { isOptedOut, OPT_OUT_FILE } from "./optout.js";
2
+ export { isOptedOut, OPT_OUT_FILE };
4
3
  /** Whether `start`, or the repository containing it, opted out: walks up through
5
4
  * `start`'s ancestors (which need not exist yet), stopping after the first one holding
6
5
  * `.git` or at the filesystem root, so a path inside an opted-out repository (`serve
@@ -8,7 +7,14 @@ export declare function isOptedOut(root: string): boolean;
8
7
  * store is deliberately not a boundary: a partition store nested in an opted-out
9
8
  * repository stays gated (fail closed). */
10
9
  export declare function optedOutAt(start: string): boolean;
11
- export declare function optOutMessage(): string;
10
+ /** The opt-out marker that gates `start` (walked as in `optedOutAt`), or null. With
11
+ * `withinRepo`, a marker counts only when the walk reaches a repository: outside one,
12
+ * an ancestor's marker gates nothing. */
13
+ export declare function optOutMarkerAt(start: string, opts?: {
14
+ withinRepo?: boolean;
15
+ }): string | null;
16
+ /** The refusal, naming the marker of the repository actually checked (#488). */
17
+ export declare function optOutMessage(marker?: string): string;
12
18
  export interface UninstallStep {
13
19
  /** Repo-relative (POSIX) where possible, else absolute. */
14
20
  path: string;
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * `.hunch/` holds the engineering memory, so it stays unless `--purge`.
14
14
  */
15
- import { existsSync, lstatSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
15
+ import { existsSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
16
16
  import { spawnSync } from "node:child_process";
17
17
  import { basename, dirname, join, relative, resolve } from "node:path";
18
18
  import { parse as parseToml } from "smol-toml";
@@ -26,16 +26,8 @@ import { gitRun, isInside, planHookRemoval, worktreeTops } from "./hooks.js";
26
26
  import { ATTR_LINES, targetRepositoryEnv } from "./mergeDriver.js";
27
27
  import { antigravityMcpFile, isHunchProviderHook, readJsonObj, TOML_END, TOML_START, windsurfMcpFile } from "./providers.js";
28
28
  import { SLASH_COMMAND_FILES } from "./scaffold.js";
29
- /** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
30
- export const OPT_OUT_FILE = ".hunch-removed";
31
- export function isOptedOut(root) {
32
- try {
33
- return lstatSync(join(root, OPT_OUT_FILE)).isFile();
34
- }
35
- catch {
36
- return false;
37
- }
38
- }
29
+ import { isOptedOut, OPT_OUT_FILE } from "./optout.js";
30
+ export { isOptedOut, OPT_OUT_FILE };
39
31
  /** Whether `start`, or the repository containing it, opted out: walks up through
40
32
  * `start`'s ancestors (which need not exist yet), stopping after the first one holding
41
33
  * `.git` or at the filesystem root, so a path inside an opted-out repository (`serve
@@ -43,19 +35,30 @@ export function isOptedOut(root) {
43
35
  * store is deliberately not a boundary: a partition store nested in an opted-out
44
36
  * repository stays gated (fail closed). */
45
37
  export function optedOutAt(start) {
38
+ return optOutMarkerAt(start) !== null;
39
+ }
40
+ /** The opt-out marker that gates `start` (walked as in `optedOutAt`), or null. With
41
+ * `withinRepo`, a marker counts only when the walk reaches a repository: outside one,
42
+ * an ancestor's marker gates nothing. */
43
+ export function optOutMarkerAt(start, opts = {}) {
44
+ let found = null;
46
45
  for (let dir = resolve(start);;) {
47
- if (isOptedOut(dir))
48
- return true;
46
+ if (!found && isOptedOut(dir)) {
47
+ found = join(dir, OPT_OUT_FILE);
48
+ if (!opts.withinRepo)
49
+ return found;
50
+ }
49
51
  if (existsSync(join(dir, ".git")))
50
- return false;
52
+ return found;
51
53
  const parent = dirname(dir);
52
54
  if (parent === dir)
53
- return false;
55
+ return opts.withinRepo ? null : found;
54
56
  dir = parent;
55
57
  }
56
58
  }
57
- export function optOutMessage() {
58
- return `Hunch was removed from this repository on purpose (${OPT_OUT_FILE}), so it will not set itself up here again. To reinstall, delete ${OPT_OUT_FILE} and run \`hunch init\`.`;
59
+ /** The refusal, naming the marker of the repository actually checked (#488). */
60
+ export function optOutMessage(marker = OPT_OUT_FILE) {
61
+ return `Hunch was removed from this repository on purpose (${marker}), so it will not set itself up here again. To reinstall, delete ${marker} and run \`hunch init\`.`;
59
62
  }
60
63
  function markerText() {
61
64
  return [
@@ -310,6 +313,7 @@ export function planUninstall(root, opts = {}) {
310
313
  p.hookConfig(".codex/hooks.json", providerEntry);
311
314
  p.hookConfig(".github/hooks/hunch.json", providerEntry);
312
315
  p.hookConfig(".windsurf/hooks.json", providerEntry);
316
+ p.hookConfig(".devin/hooks.json", providerEntry);
313
317
  p.json(".agents/hooks.json", "removed the hunch hook group", (json) => {
314
318
  if (!("hunch" in json))
315
319
  return false;
@@ -459,7 +463,7 @@ export function planUninstall(root, opts = {}) {
459
463
  }
460
464
  /** Directories Hunch's writers create; removed only when left empty. Deepest first. */
461
465
  const PRUNE_DIRS = [
462
- ".claude/commands", ".claude", ".cursor/rules", ".cursor", ".windsurf/rules", ".windsurf",
466
+ ".claude/commands", ".claude", ".cursor/rules", ".cursor", ".windsurf/rules", ".windsurf", ".devin",
463
467
  ".vscode", ".github/hooks", ".github/workflows", ".github", ".codex", ".agents",
464
468
  ];
465
469
  export function applyUninstall(plan) {
@@ -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";
@@ -1253,6 +1253,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1253
1253
  }
1254
1254
  return null;
1255
1255
  };
1256
+ // Exact-id record reads (findings, corrections, imported ADRs, why-by-id): the
1257
+ // same confirmation, gated to shared mode. Null means "re-read; absence is
1258
+ // confirmed or the store is not shared", else the abstention to return instead.
1259
+ const confirmSharedRecord = (subject) => store.mode === "shared" && store.privateDir ? confirmSharedMiss(`${subject} is absent from shared team memory`, subject) : null;
1256
1260
  // Policy reads: confirm a local policy (or its referenced proof) miss against
1257
1261
  // fresh shared memory before the service reports "not found". Public-only reads
1258
1262
  // and non-shared stores never consult the team remote.
@@ -1508,16 +1512,23 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1508
1512
  if (as_of && !asOf)
1509
1513
  return invalid(`Could not resolve as_of "${as_of}" to a commit.`);
1510
1514
  let w = store.why(target, { asOf });
1515
+ // An id-shaped target is an id miss when the store reported one (generated
1516
+ // shape) or, for a hand-written id, when nothing else matched it either.
1517
+ const idMiss = (r) => !r.byId?.record && (r.byId !== undefined || (WHY_RECORD_ID.test(target)
1518
+ && !r.decisions.length && !r.constraints.length && !r.bugs.length && !r.components.length && !r.symbols.length));
1511
1519
  // "No record <id>" is a team-absence claim in shared mode: confirm the local
1512
1520
  // miss against a fresh pull first, like hunch_current_decision. A record held
1513
1521
  // locally but outside its as_of window is not a team-absence claim: no pull.
1514
- if (w.byId && !w.byId.record && store.mode === "shared" && store.privateDir
1515
- && !(asOf && store.why(target).byId?.record)) {
1516
- const abstention = confirmSharedMiss(`record ${w.byId.id} is absent from shared team memory`, `record ${w.byId.id}`);
1522
+ const heldNow = () => !!(asOf && store.why(target).byId?.record);
1523
+ if (idMiss(w) && !heldNow()) {
1524
+ const abstention = confirmSharedRecord(`record ${w.byId?.id ?? target}`);
1517
1525
  if (abstention)
1518
1526
  return err(abstention);
1519
1527
  w = store.why(target, { asOf });
1520
1528
  }
1529
+ // A hand-written id held now but not in force at as_of: say so, like a generated id.
1530
+ if (asOf && !w.byId && idMiss(w) && heldNow())
1531
+ w = { ...w, byId: { id: target, record: null, asOf } };
1521
1532
  if (w.byId?.record)
1522
1533
  return ok(formatWhyRecord(w.byId).join("\n"));
1523
1534
  // Highest-signal first, then cap: invariants by severity, decisions by
@@ -2066,7 +2077,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2066
2077
  },
2067
2078
  }, async ({ decision_id, expected_source_hash, expected_review_hash, disposition, reviewed_by }) => {
2068
2079
  try {
2069
- const decision = store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
2080
+ const find = () => store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
2081
+ let decision = find();
2082
+ if (!decision) {
2083
+ const abstention = confirmSharedRecord(`imported ADR ${decision_id}`);
2084
+ if (abstention)
2085
+ return err(abstention);
2086
+ decision = find();
2087
+ }
2070
2088
  if (!decision)
2071
2089
  return invalid(`Imported ADR ${decision_id} is not present in the current advisory memory home.`);
2072
2090
  // Approval grants human authority, so the agent's report that the human approved
@@ -2681,7 +2699,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2681
2699
  });
2682
2700
  server.registerTool("nuryel_read", {
2683
2701
  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.",
2702
+ 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
2703
  inputSchema: ReadRequestSchema.omit({ schema: true }).shape,
2686
2704
  outputSchema: ReadResponseSchema.shape,
2687
2705
  }, async (input) => {
@@ -2745,7 +2763,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2745
2763
  });
2746
2764
  server.registerTool("nuryel_write", {
2747
2765
  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.",
2766
+ 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
2767
  inputSchema: { ...WriteRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
2750
2768
  outputSchema: WriteResultSchema.shape,
2751
2769
  }, async ({ cwd: _cwd, ...input }) => {
@@ -2850,7 +2868,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2850
2868
  const concerns = (f) => [...f.affected_files, ...f.affected_symbols].join(", ") || "(unscoped)";
2851
2869
  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
2870
  if (id) {
2853
- const f = store.recs("findings").find((r) => r.id === id);
2871
+ const find = () => store.recs("findings").find((r) => r.id === id);
2872
+ let f = find();
2873
+ if (!f) {
2874
+ const abstention = confirmSharedRecord(`finding ${id}`);
2875
+ if (abstention)
2876
+ return err(abstention);
2877
+ f = find();
2878
+ }
2854
2879
  if (!f)
2855
2880
  return ok(`No finding "${id}".`);
2856
2881
  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 +2908,16 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2883
2908
  try {
2884
2909
  if (public_only && private_only)
2885
2910
  return invalid("Choose only one of public_only or private_only.");
2911
+ // A correction missing from every home this call may read is confirmed against
2912
+ // fresh shared memory before the upgrade reports it "not found". Public-only
2913
+ // reads never consult the team remote.
2914
+ const heldCorrection = () => !!(store.getPrivateRec("constraints", constraint_id)
2915
+ || (!private_only && store.json.get("constraints", constraint_id)));
2916
+ if (!public_only && !heldCorrection()) {
2917
+ const abstention = confirmSharedRecord(`correction ${constraint_id}`);
2918
+ if (abstention)
2919
+ return err(abstention);
2920
+ }
2886
2921
  // Resolve the correction's exact home before any writes. Overlay-first is
2887
2922
  // the same selection contract as ConstitutionService.upgradeCorrection;
2888
2923
  // deriving this later from a policy id is unsafe when legacy public and
@@ -3479,6 +3514,10 @@ export async function startServer(cwd = process.cwd(), options = {}) {
3479
3514
  wireClientRoots(control, fallback);
3480
3515
  const transport = new StdioServerTransport();
3481
3516
  await control.server.connect(transport);
3517
+ // Exit through process.exit on a termination signal (same conventional codes as
3518
+ // a signal death) so NODE_V8_COVERAGE is flushed for `hunch verify --refresh-map`.
3519
+ process.once("SIGTERM", () => process.exit(143));
3520
+ process.once("SIGINT", () => process.exit(130));
3482
3521
  console.error(control.pinned
3483
3522
  ? `[hunch-mcp] serving Hunch over stdio (pinned root ${control.getRoot()}; client roots ignored)`
3484
3523
  : `[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`);
@@ -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: string;
18
+ nonce?: string;
17
19
  at: string;
18
20
  start?: string;
19
21
  }
@@ -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). Removing a stale
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
- if (typeof parsed.pid !== "number" || typeof parsed.nonce !== "string")
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;
@@ -1350,8 +1350,10 @@ export class HunchStore {
1350
1350
  const lookup = WHY_RECORD_ID.test(target) ? this.whyRecord(target, opts.canRead, opts.asOf) : undefined;
1351
1351
  if (lookup?.record)
1352
1352
  return { target, decisions: [], bugs: [], constraints: [], symbols: [], components: [], byId: lookup };
1353
- // A miss is reported only for a generated-shape id; anything else resolves as before.
1354
- const byId = lookup && WHY_GENERATED_ID.test(target) ? lookup : undefined;
1353
+ // A miss is reported for a generated-shape id, or for any id on file that was not
1354
+ // in force at `asOf` (issue #493); anything else resolves as before.
1355
+ const byId = lookup && (WHY_GENERATED_ID.test(target) || (opts.asOf && this.whyRecord(target, opts.canRead).record))
1356
+ ? lookup : undefined;
1355
1357
  const decisions = this.recs("decisions").filter(opts.canRead ?? (() => true));
1356
1358
  const bugs = this.recs("bugs").filter(opts.canRead ?? (() => true));
1357
1359
  const constraints = this.recs("constraints").filter(opts.canRead ?? (() => true));
@@ -1446,7 +1448,8 @@ export class HunchStore {
1446
1448
  }
1447
1449
  /** Transitive blast radius: every symbol/component that (in)directly depends on
1448
1450
  * `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
1449
- * walk edges BACKWARD (edges.to = current) following call/dep/import/contains. */
1451
+ * walk edges BACKWARD (edges.to = current) following call/dep/import/contains,
1452
+ * plus `references` — the only edge YAML/Helm/Kustomize emit (issue #486). */
1450
1453
  getDependents(id, maxDepth = 6) {
1451
1454
  return this.db.prepare(
1452
1455
  /* sql */ `
@@ -1455,7 +1458,7 @@ export class HunchStore {
1455
1458
  UNION
1456
1459
  SELECT e."from", up.depth + 1
1457
1460
  FROM edges e JOIN up ON e."to" = up.node
1458
- WHERE e.type IN ('calls','depends_on','imports','contains','implements') AND up.depth < ?
1461
+ WHERE e.type IN ('calls','depends_on','imports','contains','implements','references') AND up.depth < ?
1459
1462
  )
1460
1463
  SELECT up.node AS id, MIN(up.depth) AS depth,
1461
1464
  COALESCE(s.name || ' @ ' || s.file, c.name, up.node) AS via
@@ -1497,7 +1500,7 @@ export class HunchStore {
1497
1500
  UNION
1498
1501
  SELECT e."from", up.depth + 1
1499
1502
  FROM edges e JOIN up ON e."to" = up.node
1500
- WHERE e.type IN ('calls','depends_on','imports','contains','implements') AND up.depth < ?
1503
+ WHERE e.type IN ('calls','depends_on','imports','contains','implements','references') AND up.depth < ?
1501
1504
  )
1502
1505
  SELECT s.file AS file, s.name AS via, MIN(up.depth) AS depth
1503
1506
  FROM up JOIN symbols s ON s.id = up.node
@@ -126,12 +126,22 @@ export declare class JsonStore {
126
126
  * overlay. Returns the number of files removed. Invalidates the memoized load. */
127
127
  dropAll(kind: EntityKind): number;
128
128
  /** Persist a schema migration: rewrite every LOADABLE record in its current shape.
129
- * A record that still fails validation after migration is kept untouched (never
130
- * deleted) and counted as `skipped`, so migration can't lose data. The caller
131
- * bumps the manifest afterward. */
129
+ * A record that still fails validation after migration (or whose migration
130
+ * throws) is kept in its ORIGINAL shape (never deleted, never half-migrated) and
131
+ * counted as `skipped`, so migration can't lose data. The caller stamps the
132
+ * manifest at the returned `version`: SCHEMA_VERSION only when nothing was
133
+ * skipped, else the FROM version, so a manual fix plus a rerun re-applies the
134
+ * (idempotent) migrations to the skipped records instead of skipping them.
135
+ * A bad record of a kind NO pending migration touches can't be fixed by a
136
+ * rerun, so it is counted as `invalid` instead and doesn't hold the version. */
132
137
  persistMigration(): {
133
138
  migrated: number;
134
139
  skipped: number;
140
+ invalid: number;
141
+ version: number;
135
142
  };
143
+ /** Migrate + validate one raw record for persistMigration; a throwing migration
144
+ * step counts as a failure, so the record stays in its original shape. */
145
+ private tryMigrate;
136
146
  }
137
147
  export {};