@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.
- package/dist/cli/index.js +154 -64
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- package/dist/constitution/g2BehaviorCandidates.js +6 -1
- package/dist/constitution/g2Candidates.js +11 -1
- package/dist/constitution/structural.js +10 -0
- package/dist/core/correction.js +12 -7
- package/dist/core/delivery.d.ts +34 -0
- package/dist/core/delivery.js +60 -1
- package/dist/core/docanchors.js +195 -11
- package/dist/core/format.js +6 -1
- package/dist/core/glob.d.ts +25 -0
- package/dist/core/glob.js +43 -0
- package/dist/core/hookcache.d.ts +9 -2
- package/dist/core/hookcache.js +10 -3
- package/dist/core/paths.d.ts +48 -5
- package/dist/core/paths.js +89 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- package/dist/core/taskDelivery.js +27 -1
- package/dist/core/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +17 -1
- package/dist/core/taskReportHook.d.ts +19 -0
- package/dist/core/taskReportHook.js +54 -6
- package/dist/core/verifyLauncher.d.ts +21 -0
- package/dist/core/verifyLauncher.js +37 -0
- package/dist/core/workspace.d.ts +9 -0
- package/dist/core/workspace.js +15 -0
- package/dist/extractors/git.d.ts +5 -0
- package/dist/extractors/git.js +87 -10
- package/dist/extractors/indexer.d.ts +10 -0
- package/dist/extractors/indexer.js +56 -10
- package/dist/extractors/k8sManifest.d.ts +13 -0
- package/dist/extractors/k8sManifest.js +207 -23
- package/dist/extractors/landscapeDiscovery.js +9 -1
- package/dist/extractors/nativeTreeSitter.d.ts +24 -0
- package/dist/extractors/nativeTreeSitter.js +54 -1
- package/dist/extractors/parse.js +6 -1
- package/dist/integrations/claudemd.js +3 -3
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +79 -9
- package/dist/integrations/hooks.js +559 -46
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +24 -21
- package/dist/integrations/workspaceLedger.d.ts +10 -0
- package/dist/integrations/workspaceLedger.js +9 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +32 -16
- package/dist/mcp/taskReportTools.d.ts +5 -9
- package/dist/mcp/taskReportTools.js +9 -20
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/changeLedger.d.ts +9 -3
- package/dist/store/changeLedger.js +36 -10
- package/dist/store/hunchStore.d.ts +42 -2
- package/dist/store/hunchStore.js +105 -17
- package/dist/store/jsonStore.d.ts +6 -4
- package/dist/store/jsonStore.js +225 -32
- package/dist/store/merge.d.ts +7 -4
- package/dist/store/merge.js +65 -5
- package/dist/store/privateMigrate.js +10 -0
- package/dist/wiki/adopt.js +13 -2
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
*
|
|
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) =>
|
|
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
|
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -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;
|
package/dist/mcp/server.js
CHANGED
|
@@ -12,7 +12,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
12
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
13
|
import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
14
14
|
import { z } from "zod";
|
|
15
|
-
import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
|
|
15
|
+
import { hunchPaths, findRoot, toPosixTarget, repoRelativeTarget } from "../core/paths.js";
|
|
16
|
+
import { 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
|
-
|
|
459
|
-
|
|
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
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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] : [
|
|
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 —
|
|
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
|
-
|
|
928
|
-
|
|
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
|
-
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
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
|
|
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>;
|
package/dist/serve/writelock.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
/**
|
|
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 = {
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|