@davesheffer/hunch 1.39.2 → 1.40.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 (69) hide show
  1. package/dist/cli/index.js +154 -64
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/constitution/g2BehaviorCandidates.js +6 -1
  5. package/dist/constitution/g2Candidates.js +11 -1
  6. package/dist/constitution/structural.js +10 -0
  7. package/dist/core/correction.js +12 -7
  8. package/dist/core/delivery.d.ts +34 -0
  9. package/dist/core/delivery.js +60 -1
  10. package/dist/core/docanchors.js +195 -11
  11. package/dist/core/format.js +6 -1
  12. package/dist/core/glob.d.ts +25 -0
  13. package/dist/core/glob.js +43 -0
  14. package/dist/core/hookcache.d.ts +9 -2
  15. package/dist/core/hookcache.js +10 -3
  16. package/dist/core/paths.d.ts +48 -5
  17. package/dist/core/paths.js +89 -6
  18. package/dist/core/procstart.d.ts +94 -0
  19. package/dist/core/procstart.js +244 -0
  20. package/dist/core/served.d.ts +6 -3
  21. package/dist/core/served.js +10 -6
  22. package/dist/core/taskDelivery.js +27 -1
  23. package/dist/core/taskReport.d.ts +2 -0
  24. package/dist/core/taskReport.js +46 -11
  25. package/dist/core/taskReportEvidence.js +17 -1
  26. package/dist/core/taskReportHook.d.ts +19 -0
  27. package/dist/core/taskReportHook.js +54 -6
  28. package/dist/core/verifyLauncher.d.ts +21 -0
  29. package/dist/core/verifyLauncher.js +37 -0
  30. package/dist/core/workspace.d.ts +9 -0
  31. package/dist/core/workspace.js +15 -0
  32. package/dist/extractors/git.d.ts +5 -0
  33. package/dist/extractors/git.js +87 -10
  34. package/dist/extractors/indexer.d.ts +10 -0
  35. package/dist/extractors/indexer.js +56 -10
  36. package/dist/extractors/k8sManifest.d.ts +13 -0
  37. package/dist/extractors/k8sManifest.js +207 -23
  38. package/dist/extractors/landscapeDiscovery.js +9 -1
  39. package/dist/extractors/nativeTreeSitter.d.ts +24 -0
  40. package/dist/extractors/nativeTreeSitter.js +54 -1
  41. package/dist/extractors/parse.js +6 -1
  42. package/dist/integrations/claudemd.js +3 -3
  43. package/dist/integrations/hookmatch.d.ts +25 -0
  44. package/dist/integrations/hookmatch.js +58 -0
  45. package/dist/integrations/hooks.d.ts +79 -9
  46. package/dist/integrations/hooks.js +559 -46
  47. package/dist/integrations/providers.js +2 -17
  48. package/dist/integrations/scaffold.d.ts +1 -1
  49. package/dist/integrations/scaffold.js +24 -21
  50. package/dist/integrations/workspaceLedger.d.ts +10 -0
  51. package/dist/integrations/workspaceLedger.js +9 -1
  52. package/dist/mcp/server.d.ts +10 -0
  53. package/dist/mcp/server.js +32 -16
  54. package/dist/mcp/taskReportTools.d.ts +5 -9
  55. package/dist/mcp/taskReportTools.js +9 -20
  56. package/dist/serve/writelock.d.ts +21 -1
  57. package/dist/serve/writelock.js +157 -16
  58. package/dist/store/changeLedger.d.ts +9 -3
  59. package/dist/store/changeLedger.js +36 -10
  60. package/dist/store/hunchStore.d.ts +42 -2
  61. package/dist/store/hunchStore.js +105 -17
  62. package/dist/store/jsonStore.d.ts +6 -4
  63. package/dist/store/jsonStore.js +225 -32
  64. package/dist/store/merge.d.ts +7 -4
  65. package/dist/store/merge.js +65 -5
  66. package/dist/store/privateMigrate.js +10 -0
  67. package/dist/wiki/adopt.js +13 -2
  68. package/package.json +1 -1
  69. package/server.json +2 -2
@@ -21,6 +21,7 @@ import { readFileSync, existsSync, mkdirSync } from "node:fs";
21
21
  import { writeFileAtomic } from "../core/io.js";
22
22
  import { homedir } from "node:os";
23
23
  import { join, dirname } from "node:path";
24
+ import { isHunchHookCommand } from "./hookmatch.js";
24
25
  import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd } from "./claudemd.js";
25
26
  import { headFileContent, isGitCleanPath } from "../extractors/git.js";
26
27
  import { parseJsonc } from "../core/jsonc.js";
@@ -106,23 +107,7 @@ function isHunchProviderHook(entry) {
106
107
  // whose every nested hook is ours is ours; a mixed entry stays foreign.
107
108
  if (e && Array.isArray(e.hooks) && e.hooks.length && typeof e.command !== "string")
108
109
  return e.hooks.every(isHunchProviderHook);
109
- const command = typeof e?.command === "string" ? e.command : "";
110
- // Anchored to the shapes hookCommand() writes — a Hunch launcher (the pinned
111
- // npm package spec, or a …/index.js|ts path for source installs) plus a tail
112
- // of `hook --provider <name>`, tokens quoted or bare. The old unanchored
113
- // /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries like
114
- // `node ./hook/index.js` as ours and silently deleted them, violating the
115
- // leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41), so
116
- // the bare tail must still carry --provider to match; only the LEGACY
117
- // fully-quoted form (written before this quoting fix, and by hunch versions
118
- // that predate --provider) may omit it, and its quotes keep it unambiguous.
119
- // Source-checkout invocations generated by resolveInvocation point at the
120
- // CLI entry specifically. A generic `other-tool/index.js hook` is foreign
121
- // even when it happens to use Hunch's old command tail.
122
- const launcher = /@davesheffer\/hunch|(?:dist|src)[\\/]+cli[\\/]+index\.(?:js|ts)(?=["\s]|$)/.test(command);
123
- const legacyTail = /\s"hook"(?:\s+"--provider"\s+"[a-z]+")?\s*$/.test(command);
124
- const tail = /\s"?hook"?\s+"?--provider"?\s+"?[a-z]+"?\s*$/.test(command);
125
- return launcher && (legacyTail || tail);
110
+ return isHunchHookCommand(typeof e?.command === "string" ? e.command : "", true);
126
111
  }
127
112
  /** Merge our command entries into a standard `{ hooks: { Event: [] } }` file.
128
113
  * We replace only old Hunch commands and leave every foreign hook in place. */
@@ -19,7 +19,7 @@ export interface ClaudeHookInstall {
19
19
  * - UserPromptSubmit → remind the agent to consult Hunch.
20
20
  * Both invoke `hunch hook`, which reads the firmness level from .hunch/config.json
21
21
  * at run time — so changing firmness needs no settings.json edit. We own only our
22
- * entries (matched by isHunchHook): other hooks and settings are preserved, and a
22
+ * commands (matched by isHunchHookCommand): other hooks and settings are preserved, and a
23
23
  * non-empty file we cannot parse THROWS rather than clobbering the user's config.
24
24
  */
25
25
  export declare function installClaudeHooks(root: string, hookCmd: string): ClaudeHookInstall;
@@ -6,6 +6,7 @@
6
6
  import { readFileSync, existsSync, mkdirSync } from "node:fs";
7
7
  import { writeFileAtomic } from "../core/io.js";
8
8
  import { join, dirname } from "node:path";
9
+ import { isHunchHookCommand } from "./hookmatch.js";
9
10
  /** Merge a `hunch` server entry into .mcp.json, preserving other servers.
10
11
  * A non-empty file we cannot parse THROWS instead of being silently replaced
11
12
  * (con_8460b6770f — it may hold the user's other MCP servers); the caller
@@ -115,25 +116,27 @@ Reconcile decision-grounding drift for **$ARGUMENTS** (or the whole repo).
115
116
  3. Only if I explicitly say "the DECISION is stale, not the doc" (Heal B): run /capture to record a superseding decision, then return to step 2 — the prose re-derives from the new decision as a separate confirm.
116
117
  4. Report: healed (Heal A), superseded (Heal B), skipped. Never touch the graph except via an explicit Heal B capture.
117
118
  `;
118
- /** A settings.json hook entry is Hunch's if any of its commands is either the
119
- * native/source CLI entry (`…/index.js hook`) or the exact published-package
120
- * launcher written by older Hunch versions (`npx --package=…@davesheffer/hunch…
121
- * hunch hook`). Matching both generations makes an upgrade idempotent instead
122
- * of leaving the portable old hook alongside the new native invocation. The
123
- * source form still requires `index` to be a full path segment, and the npx form
124
- * requires both the scoped package and the `hunch hook` tail, so foreign hooks
125
- * are preserved. */
126
- function isHunchHook(entry) {
127
- return !!entry.hooks?.some((h) => {
128
- if (typeof h.command !== "string")
129
- return false;
130
- const command = h.command;
131
- const nativeOrSource = /(?:dist|src)[\\/]+cli[\\/]+index\.(js|ts)"?\s+hook\s*$/.test(command);
132
- const publishedNpx = /^\s*"?npx(?:\.cmd)?"?\s+/i.test(command)
133
- && /--package=(?:hunch-exact@npm:)?@davesheffer\/hunch(?:@[^"\s]+)?/.test(command)
134
- && /\s"?hunch"?\s+"?hook"?\s*$/.test(command);
135
- return nativeOrSource || publishedNpx;
136
- });
119
+ /** Strip Hunch's own commands out of one settings.json hook entry, matching with
120
+ * the SAME anchored rule the provider writers use (isHunchHookCommand, issue
121
+ * #41) so an unrelated tool that merely shares our layout — `node
122
+ * tools/lint/dist/cli/index.js hook` — is never classified as ours. Claude
123
+ * Code's hooks carry no `--provider`, hence the bare-tail variant.
124
+ *
125
+ * Filtering per COMMAND rather than per entry is what keeps a MIXED entry (our
126
+ * hook and the user's own command side by side) intact: the entry survives with
127
+ * its matcher and the user's remaining commands in order, and is dropped only
128
+ * when nothing of the user's is left. Dropping the whole entry deleted user
129
+ * hooks (con_8460b6770f, issue #310). */
130
+ function withoutHunchCommands(entry, hookCmd) {
131
+ const hooks = entry.hooks;
132
+ if (!Array.isArray(hooks))
133
+ return entry;
134
+ // The command being installed is ours by definition, whatever shape a future
135
+ // launcher takes — so a re-run stays idempotent even if the matcher lags it.
136
+ const kept = hooks.filter((h) => !(typeof h?.command === "string" && (h.command === hookCmd || isHunchHookCommand(h.command, false))));
137
+ if (kept.length === hooks.length)
138
+ return entry;
139
+ return kept.length ? { ...entry, hooks: kept } : null;
137
140
  }
138
141
  /**
139
142
  * Install the Claude Code AGENT hooks into `.claude/settings.json` so the agent
@@ -143,7 +146,7 @@ function isHunchHook(entry) {
143
146
  * - UserPromptSubmit → remind the agent to consult Hunch.
144
147
  * Both invoke `hunch hook`, which reads the firmness level from .hunch/config.json
145
148
  * at run time — so changing firmness needs no settings.json edit. We own only our
146
- * entries (matched by isHunchHook): other hooks and settings are preserved, and a
149
+ * commands (matched by isHunchHookCommand): other hooks and settings are preserved, and a
147
150
  * non-empty file we cannot parse THROWS rather than clobbering the user's config.
148
151
  */
149
152
  export function installClaudeHooks(root, hookCmd) {
@@ -176,7 +179,7 @@ export function installClaudeHooks(root, hookCmd) {
176
179
  throw new Error(`refusing to edit ${file}: hooks.${event} must be an array when present; fix it, then re-run.`);
177
180
  }
178
181
  }
179
- const keep = (arr) => (Array.isArray(arr) ? arr.filter((e) => !isHunchHook(e)) : []);
182
+ const keep = (arr) => (Array.isArray(arr) ? arr.map((entry) => withoutHunchCommands(entry, hookCmd)).filter((e) => e !== null) : []);
180
183
  json.hooks.PreToolUse = [
181
184
  ...keep(json.hooks.PreToolUse),
182
185
  { matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: hookCmd }] },
@@ -33,6 +33,16 @@ export type SnapshotOutcome = {
33
33
  status: "collision";
34
34
  record: Workspace;
35
35
  reason: string;
36
+ }
37
+ /** A PUBLIC-home snapshot is a commit on the checked-out code branch. While git is replaying
38
+ * history (rebase / merge / cherry-pick / revert / bisect) or HEAD is detached, that write
39
+ * would land in the middle of the operation — an untracked `ws_*.json` that makes
40
+ * `git rebase --continue` abort, or a commit no branch owns. Nothing is lost: the next
41
+ * branch checkout or ledger read records this machine. */
42
+ | {
43
+ status: "deferred";
44
+ record: Workspace;
45
+ reason: "git-operation-in-progress" | "detached-head";
36
46
  } | {
37
47
  status: "written";
38
48
  record: Workspace;
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { execFileSync, spawnSync } from "node:child_process";
13
13
  import { createInterface } from "node:readline";
14
- import { foreignRepoEnv, mainWorktreeRoot } from "../extractors/git.js";
14
+ import { foreignRepoEnv, gitHeadUnsettled, mainWorktreeRoot } from "../extractors/git.js";
15
15
  import { hunchPaths } from "../core/paths.js";
16
16
  import { readConfig, workspacesConfig } from "../core/config.js";
17
17
  import { loadOrCreateMachine } from "../core/machine.js";
@@ -47,6 +47,14 @@ export function recordWorkspaceSnapshot(store, root, opts = {}) {
47
47
  if (previous && Date.now() - Date.parse(previous.observed_at) < 86_400_000 && sameWorkspaceContent(previous, record)) {
48
48
  return { status: "unchanged", record, previous };
49
49
  }
50
+ // Checked as late as possible (the live snapshot above takes time, and a rebase can start or
51
+ // finish while it runs). A private overlay is its own repository, whose commits never touch
52
+ // the code branch, so only a PUBLIC home defers.
53
+ if (!isPrivate) {
54
+ const unsettled = gitHeadUnsettled(root);
55
+ if (unsettled)
56
+ return { status: "deferred", record, reason: unsettled };
57
+ }
50
58
  try {
51
59
  store.putCapture("workspaces", record, isPrivate);
52
60
  }
@@ -7,7 +7,17 @@
7
7
  * diagnostics go to stderr.
8
8
  */
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { HunchStore } from "../store/hunchStore.js";
11
+ import type { Symbol } from "../core/types.js";
10
12
  export declare const publicationVocabulary: (hunchDir: string) => RegExp[];
13
+ /** Resolve a free-form target (symbol id / name / file path) to symbol records.
14
+ * Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
15
+ * shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift
16
+ * apart on the same question. */
17
+ export declare function resolveSymbols(store: HunchStore, target: string): Symbol[];
18
+ /** Resolve a target to canonical indexed file path(s) (for file-granular blast
19
+ * radius). Falls back to the literal target so direct-scope checks still run. */
20
+ export declare function resolveFiles(store: HunchStore, target: string): string[];
11
21
  export type RootControlledServer = {
12
22
  server: McpServer;
13
23
  getRoot: () => string;
@@ -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 { 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";
@@ -454,23 +455,33 @@ function qualityNudge(rec) {
454
455
  }
455
456
  return "";
456
457
  }
457
- /** Resolve a free-form target (symbol id / name / file path) to symbol records. */
458
- function resolveSymbols(store, target) {
459
- target = toPosixTarget(target);
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);
460
466
  const syms = store.json.loadAll("symbols");
461
- const byId = syms.find((s) => s.id === target);
462
- if (byId)
463
- return [byId];
464
- const byName = syms.filter((s) => s.name === target);
465
- if (byName.length)
466
- return byName;
467
- return syms.filter((s) => s.file === target || s.file.endsWith(target));
467
+ const components = store.json.loadAll("components");
468
+ // A target that IS a real path (has a symbol, is covered by an indexed
469
+ // component even with zero symbols — README.md, package.json, ... — or, last
470
+ // resort, is a real working-tree file the index cannot see at all, issue
471
+ // #334) never falls through to the suffix tier: bare `endsWith` matched
472
+ // unrelated files that merely end in the same characters — "db.ts" matched
473
+ // "mongodb.ts" (issue #300), and a same-basename file in another directory
474
+ // leaked its rules onto a real target with no symbols of its own (issue
475
+ // #299). `store.isKnownPath` is the one definition of that question; it is
476
+ // asked here over the PUBLIC graph only, matching the records resolved below.
477
+ const indexed = store.isKnownPath(target, { symbols: syms, components });
478
+ return matchSymbolsTiered(target, syms, indexed);
468
479
  }
469
480
  /** Resolve a target to canonical indexed file path(s) (for file-granular blast
470
481
  * radius). Falls back to the literal target so direct-scope checks still run. */
471
- function resolveFiles(store, target) {
482
+ export function resolveFiles(store, target) {
472
483
  const files = new Set(resolveSymbols(store, target).map((s) => s.file));
473
- return files.size ? [...files] : [toPosixTarget(target)];
484
+ return files.size ? [...files] : [repoRelativeTarget(target, store.publicRoot)];
474
485
  }
475
486
  function pullBackoff(status, finishedAt, failures) {
476
487
  if (status === "updated" || status === "current") {
@@ -575,7 +586,7 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
575
586
  * per-host prose (CLAUDE.md, AGENTS.md) and hooks add to it, never replace it. */
576
587
  export const MCP_INSTRUCTIONS = [
577
588
  "Hunch is this repository's engineering memory: decisions, bug history, invariants, components, with provenance.",
578
- "Per user task: (1) hunch_task(action:\"start\", title) once — unless the host's prompt hook already printed a task_id, then reuse it; (2) hunch_context(target, task_id) FIRST, before reading or editing, for the file, symbol, or task phrase; (3) hunch_check_constraints(scope) before editing shared code; (4) hunch_task(action:\"finish\", task_id) before the final response and show its contribution card.",
589
+ "Per user task: (1) hunch_task(action:\"start\", title) once — skip it when the host's prompt hook already opened the task and printed its verify command, and reuse that task_id; (2) hunch_context(target, task_id) FIRST, before reading or editing, for the file, symbol, or task phrase; (3) hunch_check_constraints(scope) before editing shared code; (4) hunch_task(action:\"finish\", task_id) before the final response, showing its contribution card — required, with one exception: the host's prompt hook opened the task AND its instruction said that host closes the task AND the task used no Hunch (no hunch_* call on this task_id, no task verify check, no hook context you acted on, nothing to claim). A task you started with hunch_task is always finished by you.",
579
590
  "Then by moment: hunch_why(target) for rationale and rejected alternatives, hunch_bug_lineage before fixing a failure, hunch_record_decision after a non-trivial choice, hunch_record_correction when a human corrects you.",
580
591
  "Hosts without lifecycle hooks (Windsurf, Cursor, or Codex before its hooks are trusted) receive no automatic grounding: call these tools yourself.",
581
592
  ].join("\n");
@@ -924,8 +935,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
924
935
  if (!all.has(d.id))
925
936
  all.set(d.id, d);
926
937
  const deps = [...all.values()].sort((a, b) => a.depth - b.depth);
927
- if (!deps.length)
928
- return ok(`Nothing depends on "${symbol}" (leaf node, or not indexed).`);
938
+ // A real path the index has no symbols for reaches here too (resolveSymbols
939
+ // correctly refuses to suffix-resolve it, #334) — don't tell the caller a
940
+ // path we can confirm is real "isn't indexed".
941
+ if (!deps.length) {
942
+ const known = store.isKnownPath(repoRelativeTarget(symbol, store.publicRoot));
943
+ return ok(`Nothing depends on "${symbol}" (${known ? "leaf node, or no indexed symbols for it" : "leaf node, or not indexed"}).`);
944
+ }
929
945
  // Nearest dependents first (sorted by depth); cap the tail so a high-fan-in
930
946
  // symbol can't flood the session context.
931
947
  const lines = deps.slice(0, DEP_CAP).map((d) => ` • [depth ${d.depth}] ${d.via} (${d.id})`);
@@ -1,4 +1,5 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { verificationLauncherFor } from "../core/verifyLauncher.js";
2
3
  import { readTaskReport } from "../core/taskReport.js";
3
4
  import type { HunchStore } from "../store/hunchStore.js";
4
5
  /** A tool result must fit the host's round-trip. The full report (every
@@ -193,13 +194,8 @@ export declare function boundedTaskReportForHost(report: ReturnType<typeof readT
193
194
  };
194
195
  full_report: string;
195
196
  };
196
- /** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
197
- * loader; a published `.js` build needs nothing) and `resolve` is that
198
- * module's `import.meta.resolve`. The loader is resolved ONLY on the source
199
- * path: `import.meta.resolve` throws for a package that is not installed, and
200
- * `tsx` is a devDependency absent from every published install (#261). */
201
- export declare function verificationLauncherFor(metaUrl: string, resolve: (specifier: string) => string): {
202
- argv: string[];
203
- shell: string;
204
- };
197
+ /** Re-exported so existing imports keep working; the implementation lives in
198
+ * src/core/verifyLauncher.ts because the prompt hook needs it too and a hook
199
+ * must not pull in the MCP SDK. */
200
+ export { verificationLauncherFor };
205
201
  export declare function registerTaskReportTools(server: McpServer, getRoot: () => string, getStore: () => HunchStore): void;
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { fileURLToPath, pathToFileURL } from "node:url";
2
+ import { verificationLauncherFor } from "../core/verifyLauncher.js";
3
3
  import { TaskIdSchema, ReportClaimSchema, LessonReferenceSchema, finishReportTask, listReportTasks, readTaskReport, readLessonHistory, recordReportClaim, reportPresentationEnabled, startReportTask } from "../core/taskReport.js";
4
4
  import { persistTaskRecord } from "../core/taskRecord.js";
5
5
  import { reportSourceSnapshot, runReportConformance } from "../core/taskReportEvidence.js";
@@ -49,30 +49,19 @@ export function boundedTaskReportForHost(report) {
49
49
  return { ...minimal, deliveries: [], omitted: { ...minimal.omitted, deliveries: report.deliveries.length } };
50
50
  }
51
51
  /** Reuse the MCP server's installation, not a potentially stale global binary.
52
- * Structured argv is authoritative; the shell hint uses literal quoting. */
52
+ * `src/core/` and `src/mcp/` are siblings, so the relative entry URL is the
53
+ * same from either — but each caller passes ITS OWN import.meta. */
53
54
  function verificationLauncher() {
54
55
  return verificationLauncherFor(import.meta.url, (specifier) => import.meta.resolve(specifier));
55
56
  }
56
- /** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
57
- * loader; a published `.js` build needs nothing) and `resolve` is that
58
- * module's `import.meta.resolve`. The loader is resolved ONLY on the source
59
- * path: `import.meta.resolve` throws for a package that is not installed, and
60
- * `tsx` is a devDependency absent from every published install (#261). */
61
- export function verificationLauncherFor(metaUrl, resolve) {
62
- const dev = metaUrl.endsWith(".ts");
63
- const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, metaUrl));
64
- // `--import` takes a URL. Converting the resolved loader to a path made Node on
65
- // Windows reject it ("Received protocol 'c:'"), so every verification launched
66
- // from a source checkout there failed before running and cards showed no check.
67
- const loader = dev ? resolve("tsx") : null;
68
- const argv = [process.execPath, ...(loader ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
69
- const quote = (s) => process.platform === "win32" ? `'${s.replace(/'/g, "''")}'` : `'${s.replace(/'/g, "'\\''")}'`;
70
- return { argv, shell: `${process.platform === "win32" ? "& " : ""}${argv.map(quote).join(" ")}` };
71
- }
57
+ /** Re-exported so existing imports keep working; the implementation lives in
58
+ * src/core/verifyLauncher.ts because the prompt hook needs it too and a hook
59
+ * must not pull in the MCP SDK. */
60
+ export { verificationLauncherFor };
72
61
  export function registerTaskReportTools(server, getRoot, getStore) {
73
62
  server.registerTool("hunch_task", {
74
63
  title: "Start or finish a task's contribution report",
75
- description: "Start once per user task; pass the returned task_id to hunch_context. Finish before your final response and include the returned concise contribution card, without asking the user. Applications are explicitly agent-reported and must name an exact delivered occurrence and record hash. Completion never implies successful verification. Not for storing decisions or claiming tests passed; use the CLI task verify wrapper for observed command results.",
64
+ description: "Start once per user task, unless the host's prompt hook already opened the task and printed its verify command — then reuse that task_id and do not start. Pass the task_id to hunch_context. Finish before your final response and include the returned concise contribution card, without asking the user; skip finish only when the prompt hook's own instruction said this host closes the task and the task used no Hunch (no hunch_* call on this task_id, no verified check, no hook context you acted on, nothing to claim), and a task you started with this tool must always be finished. Applications are explicitly agent-reported and must name an exact delivered occurrence and record hash. Completion never implies successful verification. Not for storing decisions or claiming tests passed; use the CLI task verify wrapper for observed command results.",
76
65
  inputSchema: {
77
66
  action: z.enum(["start", "finish"]), task_id: TaskIdSchema.optional(),
78
67
  title: z.string().min(1).max(200).optional(),
@@ -99,7 +88,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
99
88
  task = readTaskReport(root, task_id, reportSourceSnapshot(root).hash).task;
100
89
  }
101
90
  const launcher = verificationLauncher();
102
- return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]. The default budget is 15 minutes; add --timeout <seconds> before -- for a longer suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
91
+ return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]${launcher.note}. The default budget is 15 minutes; add --timeout <seconds> before -- for a longer suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
103
92
  }
104
93
  if (!task_id)
105
94
  throw new Error("finish requires the exact task_id");
@@ -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>;
@@ -7,18 +7,28 @@
7
7
  * processes can both complete the read before either writes. An in-process mutex
8
8
  * does not span processes, so the lock is a file created with `open(path, "wx")` —
9
9
  * atomic on POSIX and Windows. A holder that dies leaves the file behind; a lock is
10
- * stealable once its owner is provably gone (pid dead on the same host) or older
11
- * than any plausible write.
10
+ * stealable once its owner is provably gone (pid dead — or RECYCLED — on the same
11
+ * host) or older than any plausible write. "Recycled" is decided by comparing the
12
+ * owner's recorded process-instance token with the current holder's, by string
13
+ * equality and never by clock arithmetic (see core/procstart.ts). Removing a stale
14
+ * lock is itself serialized behind an exclusive `.reclaim` claim directory, because
15
+ * judging and removing are two steps and two contenders can otherwise both "win"
16
+ * (see `takeOver`).
12
17
  */
13
18
  import { hostname } from "node:os";
14
19
  import { randomBytes } from "node:crypto";
15
- import { closeSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
20
+ import { closeSync, mkdirSync, openSync, readFileSync, rmSync, rmdirSync, statSync, writeSync } from "node:fs";
16
21
  import { join } from "node:path";
22
+ import { heldLockNonces, judgeSameHostOwner, selfStartToken, startTokenOf } from "../core/procstart.js";
17
23
  /** Longest a write may hold the lock before another process may steal it: a put plus a
18
24
  * git commit and push — seconds, not minutes. */
19
25
  export const STALE_AFTER_MS = 60_000;
20
26
  /** Longest a writer waits before giving up and telling the caller. */
21
27
  export const ACQUIRE_TIMEOUT_MS = 10_000;
28
+ /** Longest a takeover claim may sit before another contender may clear it. The
29
+ * claimed section is a few syscalls, so anything this old means its claimer
30
+ * crashed inside it. */
31
+ export const CLAIM_STALE_MS = 10_000;
22
32
  const POLL_MS = 25;
23
33
  export class WriteLockTimeout extends Error {
24
34
  path;
@@ -42,7 +52,13 @@ function readOwner(path) {
42
52
  const parsed = JSON.parse(readFileSync(path, "utf8"));
43
53
  if (typeof parsed.pid !== "number" || typeof parsed.nonce !== "string")
44
54
  return undefined;
45
- return { pid: parsed.pid, host: typeof parsed.host === "string" ? parsed.host : "", nonce: parsed.nonce, at: typeof parsed.at === "string" ? parsed.at : "" };
55
+ return {
56
+ pid: parsed.pid,
57
+ host: typeof parsed.host === "string" ? parsed.host : "",
58
+ nonce: parsed.nonce,
59
+ at: typeof parsed.at === "string" ? parsed.at : "",
60
+ ...(typeof parsed.start === "string" ? { start: parsed.start } : {}),
61
+ };
46
62
  }
47
63
  catch {
48
64
  return undefined; // released between our open and this read, or truncated by a crash — age decides
@@ -57,7 +73,7 @@ function pidAlive(pid) {
57
73
  return error.code === "EPERM"; // exists, another user — alive for our purposes
58
74
  }
59
75
  }
60
- function stealable(path, owner, now) {
76
+ function stealable(path, owner, now, token) {
61
77
  let ageMs;
62
78
  try {
63
79
  ageMs = now - statSync(path).mtimeMs;
@@ -71,15 +87,124 @@ function stealable(path, owner, now) {
71
87
  // their record and ledger updates. The age fallback is only safe when the
72
88
  // owner is from another host (whose PID we cannot probe) or its metadata is
73
89
  // unreadable.
74
- if (owner && owner.host === hostname())
75
- return !pidAlive(owner.pid);
90
+ //
91
+ // The one exception is a PID that is alive but is no longer the OWNER: PIDs
92
+ // are recycled, and a container restart routinely gives the new writer the
93
+ // dead one's exact pid and hostname, so the orphaned lock names the contender
94
+ // itself and is never taken over again (issue #287). The owner's recorded
95
+ // process-instance token settles it by plain string equality — no clock is
96
+ // read, because the lock's mtime and a start time come from different clocks
97
+ // and any skew between them would "prove" a live owner recycled and steal its
98
+ // lock. Anything the token cannot settle stays "not proven".
99
+ //
100
+ // The whole table lives in core/procstart.ts so this lock and jsonStore's
101
+ // `.rmw-lock` can never drift apart; "age" means it proved nothing and the
102
+ // fallback below governs, exactly as for a foreign host.
103
+ if (owner && owner.host === hostname()) {
104
+ const verdict = judgeSameHostOwner(owner, {
105
+ alive: pidAlive,
106
+ self: token.self,
107
+ tokenOf: token.of,
108
+ platform: token.platform ?? process.platform,
109
+ selfPid: process.pid,
110
+ heldNonces: heldLockNonces,
111
+ });
112
+ if (verdict !== "age")
113
+ return verdict === "stale";
114
+ }
76
115
  return ageMs > STALE_AFTER_MS;
77
116
  }
78
117
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
79
- /** Hold the partition's lock across `fn` (sync or async); released even if it throws. */
118
+ /** The claim a contender must hold to REMOVE a stale lock. A sibling directory,
119
+ * created with the same exclusive `mkdir` the commit lock uses
120
+ * (src/extractors/git.ts `acquireCommitLock`) — a directory because an empty one
121
+ * is invisible to git and can never be staged. */
122
+ function claimPath(lockPath) {
123
+ return `${lockPath}.reclaim`;
124
+ }
125
+ /**
126
+ * Remove a stale lock under an exclusive claim, and report whether the path may
127
+ * now be free. "judge stale → rm → create" is NOT atomic: two contenders can
128
+ * both judge the same corpse stale, the first removes it and creates its own,
129
+ * and the second then removes that FRESH lock and creates a second holder
130
+ * (issue #287). Serializing the removal behind one claim closes it.
131
+ *
132
+ * INVARIANT: a stale lock's path can only be vacated by a claim holder (its
133
+ * owner is dead and will never release), and only one contender holds the claim
134
+ * at a time — so between the re-judge below and the `rm` nothing can replace the
135
+ * lock. A contender that judged stale a moment earlier re-judges under the
136
+ * claim, sees the winner's fresh live lock, and removes nothing.
137
+ *
138
+ * Residuals accepted:
139
+ * (i) an AGE-judged lock (foreign host, unprobeable owner, or unreadable owner
140
+ * metadata) whose owner is actually alive and releases inside the
141
+ * re-judge→rm window — the same class as the already-accepted "stolen after
142
+ * STALE_AFTER_MS";
143
+ * (ii) two contenders racing to clear a STRANDED claim. A claim's staleness is
144
+ * judged by ITS MTIME against OUR clock — the one clock comparison left in
145
+ * the takeover path — and there is no claim-nonce file to fall back on. So
146
+ * with filesystem/host clock skew beyond CLAIM_STALE_MS every LIVE claim
147
+ * looks stranded, and with three or more simultaneous contenders exclusion
148
+ * degrades to the pre-fix race. Without such skew it needs a claim section
149
+ * that is stranded plus simultaneous removers — and "stranded" is NOT only a
150
+ * crash: a STALL longer than CLAIM_STALE_MS between the re-judge and the
151
+ * `rm` (SIGSTOP, a VM pause or live migration, a laptop sleep) does it too.
152
+ * One other contender is then enough: it clears the "stranded" claim, takes
153
+ * over and publishes a LIVE lock, and the resumed claimer's `rm` removes
154
+ * that live lock; the unconditional `rmdirSync(claim)` in the `finally` can
155
+ * likewise remove a SUCCESSOR's claim. Same probability class as the age
156
+ * steal we already accept — a process stalled past the stale window while
157
+ * another writes — which is why it is accepted here too;
158
+ * (iii) a stranded claim blocks any takeover for up to CLAIM_STALE_MS —
159
+ * bounded, needing the same crash OR over-CLAIM_STALE_MS stall; a filesystem
160
+ * clock AHEAD of this host extends that block by the amount of the skew;
161
+ * (iv) MIXED VERSIONS: a pre-fix `hunch` ignores `.reclaim` entirely, so the
162
+ * takeover race persists until every writer on the store is upgraded.
163
+ */
164
+ function takeOver(path, token) {
165
+ const claim = claimPath(path);
166
+ try {
167
+ mkdirSync(claim);
168
+ }
169
+ catch (error) {
170
+ if (error.code === "EEXIST") {
171
+ // Someone else is mid-takeover — or crashed inside one. Clear only a
172
+ // provably stranded claim; either way, do not take over this round (the
173
+ // caller falls through to its deadline check and sleep, never a hot spin).
174
+ try {
175
+ if (Date.now() - statSync(claim).mtimeMs > CLAIM_STALE_MS)
176
+ rmdirSync(claim);
177
+ }
178
+ catch { /* vanished, or another contender cleared it first */ }
179
+ }
180
+ return false;
181
+ }
182
+ try {
183
+ // RE-JUDGE from scratch under the claim: the lock we judged stale may already
184
+ // have been replaced by the winner's live one.
185
+ const current = readOwner(path);
186
+ if (!stealable(path, current, Date.now(), token))
187
+ return false;
188
+ rmSync(path, { force: true });
189
+ return true;
190
+ }
191
+ finally {
192
+ try {
193
+ rmdirSync(claim);
194
+ }
195
+ catch { /* best effort; a stranded claim is cleared by age */ }
196
+ }
197
+ }
198
+ /** Hold the partition's lock across `fn` (sync or async); released even if it throws.
199
+ *
200
+ * `opts.startToken` is an INTERNAL TEST SEAM, not API: it lets the test suite
201
+ * drive the process-identity rows of the decision table on a platform that has
202
+ * no such identity. Callers outside this repo must leave it unset — the real
203
+ * probe (`selfStartToken`/`startTokenOf`) is the only sound one. */
80
204
  export async function withWriteLock(hunchDir, fn, opts = {}) {
81
205
  const path = writeLockPath(hunchDir);
82
206
  const timeoutMs = opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS;
207
+ const token = opts.startToken ?? { self: selfStartToken(), of: startTokenOf };
83
208
  const nonce = randomBytes(8).toString("hex");
84
209
  const started = Date.now();
85
210
  let fd;
@@ -87,22 +212,31 @@ export async function withWriteLock(hunchDir, fn, opts = {}) {
87
212
  for (;;) {
88
213
  try {
89
214
  fd = openSync(path, "wx");
215
+ // Synchronously adjacent to the successful create, before any await: from
216
+ // here on this nonce is one WE hold, so our own later calls do not mistake
217
+ // it for a predecessor's (and adding it before the open would make a mere
218
+ // waiter look like a holder).
219
+ heldLockNonces.add(nonce);
90
220
  break;
91
221
  }
92
222
  catch (error) {
93
223
  if (error.code !== "EEXIST")
94
224
  throw error;
95
225
  lastOwner = readOwner(path);
96
- if (stealable(path, lastOwner, Date.now())) {
97
- rmSync(path, { force: true });
226
+ if (stealable(path, lastOwner, Date.now(), token) && takeOver(path, token))
98
227
  continue;
99
- }
100
228
  if (Date.now() - started >= timeoutMs)
101
229
  throw new WriteLockTimeout(path, lastOwner, Date.now() - started);
102
230
  await sleep(POLL_MS);
103
231
  }
104
232
  }
105
- const owner = { pid: process.pid, host: hostname(), nonce, at: new Date().toISOString() };
233
+ const owner = {
234
+ pid: process.pid,
235
+ host: hostname(),
236
+ nonce,
237
+ at: new Date().toISOString(),
238
+ ...(token.self !== null ? { start: token.self } : {}),
239
+ };
106
240
  try {
107
241
  writeSync(fd, JSON.stringify(owner));
108
242
  }
@@ -113,10 +247,17 @@ export async function withWriteLock(hunchDir, fn, opts = {}) {
113
247
  }
114
248
  finally {
115
249
  // Only remove a lock that is still ours: if we overran STALE_AFTER_MS and were stolen
116
- // from, the file now protects another writer.
117
- const current = readOwner(path);
118
- if (!current || current.nonce === nonce)
119
- rmSync(path, { force: true });
250
+ // from, the file now protects another writer. The nonce is dropped in a
251
+ // `finally` of its own, so a throw from the conditional remove can never
252
+ // leave us vouching for a lock we no longer hold.
253
+ try {
254
+ const current = readOwner(path);
255
+ if (!current || current.nonce === nonce)
256
+ rmSync(path, { force: true });
257
+ }
258
+ finally {
259
+ heldLockNonces.delete(nonce);
260
+ }
120
261
  }
121
262
  }
122
263
  //# sourceMappingURL=writelock.js.map