@davesheffer/hunch 1.42.0 → 1.44.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 (56) hide show
  1. package/README.md +14 -0
  2. package/dist/cli/index.js +206 -37
  3. package/dist/core/changeProof.js +2 -1
  4. package/dist/core/commitrepair.d.ts +9 -3
  5. package/dist/core/commitrepair.js +23 -6
  6. package/dist/core/correction.d.ts +1 -1
  7. package/dist/core/correction.js +4 -3
  8. package/dist/core/delivery.d.ts +12 -0
  9. package/dist/core/delivery.js +4 -4
  10. package/dist/core/docanchors.d.ts +11 -2
  11. package/dist/core/docanchors.js +232 -65
  12. package/dist/core/drift.d.ts +1 -1
  13. package/dist/core/drift.js +10 -3
  14. package/dist/core/eol.d.ts +2 -0
  15. package/dist/core/eol.js +6 -0
  16. package/dist/core/hookcache.d.ts +22 -0
  17. package/dist/core/hookcache.js +53 -2
  18. package/dist/core/procstart.d.ts +15 -4
  19. package/dist/core/procstart.js +56 -18
  20. package/dist/core/publication.js +17 -3
  21. package/dist/core/runbookDraft.d.ts +7 -0
  22. package/dist/core/runbookDraft.js +43 -0
  23. package/dist/core/taskReportHook.js +1 -1
  24. package/dist/core/taskSelection.d.ts +67 -0
  25. package/dist/core/taskSelection.js +196 -0
  26. package/dist/core/taskTouched.d.ts +2 -0
  27. package/dist/core/taskTouched.js +1 -1
  28. package/dist/extractors/git.d.ts +36 -6
  29. package/dist/extractors/git.js +86 -9
  30. package/dist/extractors/indexer.js +53 -14
  31. package/dist/extractors/k8sManifest.d.ts +31 -1
  32. package/dist/extractors/k8sManifest.js +133 -8
  33. package/dist/integrations/claudemd.d.ts +2 -0
  34. package/dist/integrations/claudemd.js +4 -2
  35. package/dist/integrations/gitignore.d.ts +7 -0
  36. package/dist/integrations/gitignore.js +18 -0
  37. package/dist/integrations/hooks.d.ts +15 -0
  38. package/dist/integrations/hooks.js +49 -1
  39. package/dist/integrations/mergeDriver.d.ts +2 -0
  40. package/dist/integrations/mergeDriver.js +2 -2
  41. package/dist/integrations/providers.d.ts +7 -0
  42. package/dist/integrations/providers.js +6 -5
  43. package/dist/integrations/scaffold.d.ts +5 -0
  44. package/dist/integrations/scaffold.js +14 -8
  45. package/dist/integrations/uninstall.d.ts +40 -0
  46. package/dist/integrations/uninstall.js +435 -0
  47. package/dist/mcp/server.d.ts +2 -1
  48. package/dist/mcp/server.js +58 -4
  49. package/dist/mcp/taskReportTools.js +1 -1
  50. package/dist/serve/writelock.d.ts +7 -0
  51. package/dist/serve/writelock.js +30 -3
  52. package/dist/store/changeLedger.d.ts +5 -2
  53. package/dist/store/changeLedger.js +88 -37
  54. package/dist/store/hunchStore.js +4 -1
  55. package/package.json +3 -3
  56. package/server.json +2 -2
@@ -23,19 +23,23 @@
23
23
  * puts two writers inside the mutex. Reproduced by simply `utimes`-ing a held
24
24
  * lock 30 s into the past. Nothing in this file may read a clock.
25
25
  *
26
- * REMAINING GAP. Only linux exposes a spawn-free, stable process-instance id
27
- * (`/proc/<pid>/stat` field 22 plus the boot id and the pid/time namespaces). On
28
- * macOS and Windows a DIFFERENT live process that recycled the owner's pid
29
- * therefore stays "not proven" and keeps the lock until the age rule applies —
30
- * today's behaviour, and the safe direction. `ps -o lstart` and PowerShell's
31
- * `Get-Process ... StartTime` were rejected: both cost a spawn per probe on the
32
- * contended path, and both return locale/TZ-dependent strings we would have to
33
- * parse back into a comparable identity. Gating such a probe on age does not
34
- * rescue them either: the CONTENDER can read the current holder's start time,
35
- * but a verdict needs something to compare it WITH — either a start time the
36
- * OWNER recorded when it took the lock (which puts that spawn on every writing
37
- * process, not just the contended path) or a start-time-vs-mtime comparison,
38
- * i.e. exactly the cross-clock arithmetic rejected above.
26
+ * OFF LINUX (issue #287). Only linux exposes a spawn-free, stable process-instance
27
+ * id (`/proc/<pid>/stat` field 22 plus the boot id and the pid/time namespaces), so
28
+ * on macOS and Windows a DIFFERENT live process that recycled the owner's pid is
29
+ * "unproven" here, which this table treats as live. The write lock closes that
30
+ * forever-deadlock on its own rare path, once a lock already looks stale: it asks
31
+ * the OS when the process now holding the pid was CREATED (`processStartedAt`, a
32
+ * spawn) and compares that with the `at` the owner recorded when it took the lock.
33
+ * A process created after the lock was taken cannot have taken it. Both instants
34
+ * are the HOST's wall clock, read on this same host, so the filesystem clock never
35
+ * enters; only a backward clock step of more than the slack between the owner's
36
+ * start and its acquisition could fake it. macOS and Windows record that creation
37
+ * instant absolutely; linux does not (`ps lstart` is boot time + uptime, so a
38
+ * forward clock step after boot moves every live process's "creation" forward and
39
+ * would fake a reuse), so on linux the probe reports nothing and a tokenless
40
+ * record keeps main's pid rule — linux writers record a token instead. The earlier objection to such a probe —
41
+ * that it needs either an owner-recorded start time (a spawn on every write) or a
42
+ * start-vs-mtime comparison — missed that `at` is already in every record.
39
43
  *
40
44
  * UNSUPPORTED TOPOLOGIES. A linux-written token carries its boot id and its pid
41
45
  * and time namespaces, so a lock written under another boot or another namespace
@@ -51,12 +55,14 @@
51
55
  * while our own token is memoized from before the checkpoint — so a restored
52
56
  * holder may judge itself against a token that no longer describes it. Tokenless
53
57
  * records (a legacy pre-token hunch, any non-linux writer, an unreadable /proc)
54
- * get main's unchanged pid rule — a live same-host pid keeps its lock, a dead one
55
- * is stealable — with exactly one exception, the own-pid + foreign-nonce row
58
+ * get main's unchanged pid rule — a live same-host pid keeps its lock (unless the
59
+ * write lock's creation-time check above proves the pid reused), a dead one is
60
+ * stealable — with exactly one exception, the own-pid + foreign-nonce row
56
61
  * described in `judgeSameHostOwner`, which gets the AGE rule instead of main's
57
62
  * "live forever".
58
63
  */
59
64
  import { readFileSync, readlinkSync } from "node:fs";
65
+ import { spawnSync } from "node:child_process";
60
66
  /** `starttime` in clock ticks since boot, as the exact decimal STRING the kernel
61
67
  * printed — it is an identity component, never a quantity, so it is never
62
68
  * converted to a Number (which would lose precision above 2^53 and invite
@@ -237,8 +243,40 @@ export function judgeSameHostOwner(owner, env) {
237
243
  // that a live writer may still hold.
238
244
  if (owner.pid === env.selfPid && owner.nonce !== undefined)
239
245
  return "age";
240
- // A nonce-less own-pid record stays live: there is nothing to compare, and a
241
- // live pid is never stolen on a guess.
242
- return "live";
246
+ // 7. A live pid no token can compare — another process's, or a nonce-less
247
+ // record of our own pid. Never stolen on a guess: "unproven" is "live" to a
248
+ // caller that has nothing better. The write lock asks the OS for that
249
+ // process's creation time, which can prove the pid was reused (issue #287).
250
+ return "unproven";
251
+ }
252
+ /** Wall-clock ms at which the process now holding `pid` was created, from the
253
+ * OS, or null when it cannot be read. SPAWNS (PowerShell on Windows, `ps` on
254
+ * macOS), so it is for rare paths only — a lock that already looks stale —
255
+ * never for every acquisition. Precision is a second on `ps`. Null on every
256
+ * other platform: linux derives `lstart` from boot time, which a forward clock
257
+ * step shifts for live processes too (see the header). */
258
+ export function processStartedAt(pid) {
259
+ if (!Number.isSafeInteger(pid) || pid <= 0)
260
+ return null;
261
+ try {
262
+ if (process.platform === "win32") {
263
+ // Epoch milliseconds, not a formatted date: a custom format string follows the
264
+ // culture's time separator (fi-FI prints `09.04.19`), which Date.parse rejects.
265
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `([DateTimeOffset](Get-Process -Id ${pid} -ErrorAction Stop).StartTime).ToUnixTimeMilliseconds()`], { encoding: "utf8", timeout: 3_000, windowsHide: true });
266
+ const ms = r.status === 0 && /^\d+$/.test(r.stdout.trim()) ? Number(r.stdout.trim()) : NaN;
267
+ return Number.isFinite(ms) ? ms : null;
268
+ }
269
+ if (process.platform !== "darwin")
270
+ return null;
271
+ const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 3_000, env: { ...process.env, LC_ALL: "C", LANG: "C" } });
272
+ const m = /^\s*\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})\s+(\d{4})\s*$/.exec(r.status === 0 ? r.stdout : "");
273
+ const month = m ? ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].indexOf(m[1]) : -1;
274
+ if (!m || month < 0)
275
+ return null;
276
+ return new Date(Number(m[6]), month, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])).getTime(); // ps prints local time
277
+ }
278
+ catch {
279
+ return null;
280
+ }
243
281
  }
244
282
  //# sourceMappingURL=procstart.js.map
@@ -33,9 +33,23 @@ export function isStructural(hit) {
33
33
  * `/Users/me/repo` appears in src/integrations/claudeConfig.ts and its test as an
34
34
  * illustration; flagging those would train everyone to ignore the scanner. */
35
35
  const PLACEHOLDER_USER = /^(me|you|user|username|<[^>]+>|\$\{[^}]+\}|example|test|foo|bar)$/i;
36
+ /** A regex quoted in a record (`\/home\/([^/]+)`, `C:\\Users\\([^\\]+)`) names a
37
+ * pattern, not a user: its capture starts with a regex metacharacter and, past a
38
+ * `(?<name>` label, holds no word. A group that lists names (`(alice|bob)`,
39
+ * `{alice,bob}`) still names users. */
40
+ const PATTERN_USER = /^[([*^{|+?]/;
41
+ function isPatternUser(who) {
42
+ return PATTERN_USER.test(who) && !/[A-Za-z]{3,}/.test(who.replace(/^\(\?<\w+>/, ""));
43
+ }
44
+ /** Home directories in the forms tools print them: a drive path (also as a URI's
45
+ * `c%3A`), a POSIX path after any separator (`file:///Users/x`, `cwd=/home/x`,
46
+ * `git -C /Users/x`) but not after a drive letter's colon, which the drive rule
47
+ * reports, and Git Bash's `/c/Users/x` or WSL's `/mnt/c/Users/x`. A letter right
48
+ * before the colon makes it a scheme (`file:/Users/x`), not a drive. */
36
49
  const MACHINE_PATH = [
37
- /[A-Za-z]:[\\/]Users[\\/]([^\\/"'\s,)\]]+)/g,
38
- /(?:^|[\s"'(])\/(?:Users|home)\/([^/"'\s,)\]]+)/g,
50
+ /(?<![A-Za-z])[A-Z](?::|%3A)[\\/]Users[\\/]([^\\/"'\s,)\]]+)/gi,
51
+ /(?:^|[^A-Za-z0-9_.~])(?<!(?<![A-Za-z])[A-Za-z]:)\/(?:Users|home)\/([^/"'\s,)\]]+)/g,
52
+ /(?:^|[^A-Za-z0-9_.~])\/(?:mnt\/)?[A-Za-z]\/Users\/([^/"'\s,)\]]+)/g,
39
53
  ];
40
54
  /** A path INTO the overlay (dir + file), not a bare mention of the feature. The
41
55
  * gitignore entry and the CLAUDE.md description name `.hunch-private` legitimately;
@@ -151,7 +165,7 @@ export function scanRecord(record, opts = {}) {
151
165
  for (const re of MACHINE_PATH) {
152
166
  for (const m of text.matchAll(re)) {
153
167
  const who = m[1] ?? "";
154
- if (PLACEHOLDER_USER.test(who))
168
+ if (PLACEHOLDER_USER.test(who) || isPatternUser(who))
155
169
  continue;
156
170
  hits.push({ kind: "machine-path", field, excerpt: clip(m[0]) });
157
171
  }
@@ -0,0 +1,7 @@
1
+ export declare function runbookDraft(commits: readonly {
2
+ subject: string;
3
+ files: readonly string[];
4
+ }[], rangeFiles: readonly string[]): {
5
+ steps: string[];
6
+ files: string[];
7
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The deterministic runbook draft (`hunch runbook <range> --task`): the commits and files
3
+ * of a range that describe the work, without Hunch's own bookkeeping (issue #425). With
4
+ * auto-commit on, a range interleaves capture commits (`hunch: capture …`) whose files are
5
+ * the store (.hunch/**) and the grounding docs flushCapture regenerates beside it.
6
+ *
7
+ * Same notion of "Hunch-owned" as sync (src/synthesis/synthesize.ts) and the task report
8
+ * (src/core/taskTouched.ts): the store is never the work; a grounding doc is Hunch's when
9
+ * it changed alongside the store, and the work's when a human edited it on its own.
10
+ */
11
+ import { HUNCH_COMMIT_SUBJECT, HUNCH_MANAGED_FILES } from "./taskTouched.js";
12
+ /** Grounding docs that are wholly generated (no user text): never the work. */
13
+ const FULLY_MANAGED = new Set([".cursor/rules/hunch.mdc", ".windsurf/rules/hunch.md"]);
14
+ const STORE_SEGMENTS = new Set([".hunch", ".hunch-cache"]);
15
+ const norm = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
16
+ /** The store lives at the repository root; a user folder named .hunch further down is theirs. */
17
+ const isStore = (p) => STORE_SEGMENTS.has(norm(p).split("/")[0]);
18
+ export function runbookDraft(commits, rangeFiles) {
19
+ const steps = [];
20
+ const workDocs = new Set(); // grounding docs a non-capture commit edited on its own
21
+ for (const c of commits) {
22
+ if (HUNCH_COMMIT_SUBJECT.test(c.subject))
23
+ continue;
24
+ const files = c.files.map(norm);
25
+ const touchesStore = files.some(isStore);
26
+ const work = files.filter((f) => !isStore(f) && !FULLY_MANAGED.has(f) && !(touchesStore && HUNCH_MANAGED_FILES.has(f)));
27
+ if (files.length && !work.length)
28
+ continue; // only Hunch's own paths
29
+ if (c.subject.trim())
30
+ steps.push(c.subject); // an empty-message commit is no step
31
+ for (const f of work)
32
+ if (HUNCH_MANAGED_FILES.has(f))
33
+ workDocs.add(f);
34
+ }
35
+ const files = rangeFiles.filter((raw) => {
36
+ const f = norm(raw);
37
+ if (isStore(f) || FULLY_MANAGED.has(f))
38
+ return false;
39
+ return !HUNCH_MANAGED_FILES.has(f) || workDocs.has(f);
40
+ });
41
+ return { steps, files };
42
+ }
43
+ //# sourceMappingURL=runbookDraft.js.map
@@ -200,7 +200,7 @@ export function taskInstruction(task, cwdLiteral, provider, launcher = verificat
200
200
  const finishRule = HOST_CLOSES_TASK.has(provider) ? "finish only if this task used Hunch" : `finish it yourself with hunch_task(action: "finish", task_id, cwd)`;
201
201
  return `Hunch report for this prompt: ${task.task_id} (cwd: ${cwdLiteral}). Use it instead of any earlier ID. Never call hunch_task start. Checks: ${l.shell} task verify ${task.task_id} -- <command> [arguments]${l.note ?? ""}. Same rules as this session's first report; ${finishRule}.`;
202
202
  }
203
- verify = ` Never call hunch_task start for it. For checks, run: ${l.shell} task verify ${task.task_id} -- <command> [arguments]${l.note ?? ""}. Default budget 15 min; add --timeout <seconds> before -- for longer suites.`;
203
+ verify = ` Never call hunch_task start for it. For checks, run: ${l.shell} task verify ${task.task_id} -- <command> [arguments]${l.note ?? ""}. Verify with the tests that cover your change (the files you edited and their tests); the full suite is CI's job. The default budget is 15 min; add --timeout <seconds> before -- only if the whole suite is really needed.`;
204
204
  }
205
205
  catch {
206
206
  return `${head} Call hunch_task(action: "start", task_id: "${task.task_id}", title: ${JSON.stringify(task.title)}, cwd: ${cwdLiteral}) to obtain verification_argv, and finish with hunch_task(action: "finish", task_id, cwd) before responding and show its card.`;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Task-scored memory selection (memory-selection design, point 1-3; Gate A v5).
3
+ *
4
+ * The pre-edit hook used to deliver every record anchored to a file, so a hub
5
+ * file filled the headline cap with memory unrelated to the task. This selector
6
+ * scores the live store against the prompt ONCE per task; file grounding then
7
+ * keeps only the decisions, bugs and findings that qualified (constraints are
8
+ * exempt: they are scoped rules, not relevance guesses).
9
+ *
10
+ * PURE on purpose: no fs, no store. Inputs are passed in, so an offline replay
11
+ * can run it against an old store snapshot. The rule is fixed before
12
+ * measurement (no vectors in this step): a record qualifies on >= 2 distinct
13
+ * task terms in its title/rationale, or on a repo path the prompt names that
14
+ * matches the record's files or scope. Below that, nothing (silence, no filler).
15
+ * A term common to many live records (document frequency above the cap) names
16
+ * no task and does not count toward the two.
17
+ */
18
+ import type { Bug, Constraint, Decision, Finding } from "./types.js";
19
+ export interface TaskSelectionRecords {
20
+ decisions: readonly Decision[];
21
+ bugs: readonly Bug[];
22
+ constraints: readonly Constraint[];
23
+ findings: readonly Finding[];
24
+ }
25
+ export type TaskSelectionKind = "decision" | "bug" | "constraint" | "finding";
26
+ export interface TaskSelection {
27
+ /** Every record clearing the threshold (ids only). */
28
+ qualifying: string[];
29
+ /** At most k, ordered; blocking constraints are never listed (they arrive at edit time). */
30
+ top: Array<{
31
+ id: string;
32
+ kind: TaskSelectionKind;
33
+ title: string;
34
+ }>;
35
+ }
36
+ export declare function isFilterableSelectionId(id: string): boolean;
37
+ export declare function isBareFollowUp(prompt: string): boolean;
38
+ export interface TaskSelectionOptions {
39
+ /** Prompt-time list size (design: K = 3). */
40
+ k?: number;
41
+ /** Optional existence check for a repo-relative path the prompt names. */
42
+ pathExists?: (path: string) => boolean;
43
+ /** Repository root, only to strip it from absolute paths in the prompt (string work, no fs). */
44
+ root?: string;
45
+ }
46
+ /** Document-frequency cap, fixed before the pilot replay: a prompt term counts
47
+ * only when at most max(MIN_DF_CAP, ceil(DF_CAP_RATIO × live records)) records
48
+ * contain it. */
49
+ export declare const MIN_DF_CAP = 3;
50
+ export declare const DF_CAP_RATIO = 0.05;
51
+ /** The live slice the pre-edit grounding would ever deliver at HEAD: decisions and
52
+ * constraints still in force (the store's why() window plus delivery's retired
53
+ * test), every bug (why() keeps fixed ones as lessons), and findings whose triage
54
+ * liveFindingsFor() keeps. */
55
+ export declare function liveSelectionRecords(all: TaskSelectionRecords): TaskSelectionRecords;
56
+ /** Repo paths the prompt names: a markdown link `[label](target)` reads as its
57
+ * target, then a whitespace token containing `/` or ending in a file extension,
58
+ * stripped of quoting/punctuation/unmatched brackets, a trailing `:line[:col]`
59
+ * or `:start-end` and a possessive `'s`, forward-slashed, repo-relative. A drive-letter root compares
60
+ * case-insensitively (Windows paths are case-insensitive on every host). */
61
+ export declare function promptPaths(prompt: string, root?: string): string[];
62
+ /** Score live records against one prompt. Text fields per kind (title + rationale):
63
+ * decision title/context/decision; bug title/symptom/root_cause; constraint
64
+ * statement/rationale; finding title/observation. Path anchors: decision
65
+ * related_files, bug/finding affected_files, constraint scope globs. Priority
66
+ * ties break on the builder profile's kind/severity score in delivery.ts. */
67
+ export declare function selectForTask(records: TaskSelectionRecords, promptText: string, opts?: TaskSelectionOptions): TaskSelection;
@@ -0,0 +1,196 @@
1
+ import { lexicalTokens, PROFILE_BASE_SCORE, SEVERITY } from "./delivery.js";
2
+ import { pathMatchesGlob } from "./glob.js";
3
+ /** Id prefixes of the kinds file grounding filters by a selection (ids.ts:
4
+ * decisionId/bugId/findingId). Constraints (con_) always pass, so a selection
5
+ * holding no id with one of these prefixes filters nothing useful — it would
6
+ * only hide every decision, bug and finding — and counts as no selection. */
7
+ const FILTERABLE_ID_PREFIXES = ["dec_", "bug_", "fnd_"];
8
+ export function isFilterableSelectionId(id) {
9
+ return FILTERABLE_ID_PREFIXES.some((prefix) => id.startsWith(prefix));
10
+ }
11
+ /** Words a bare follow-up is made of ("continue", "go on", "yes do it", "ok,
12
+ * next step please"). A prompt of only these names no task of its own and
13
+ * inherits the continued task's selection; any other word — a verb and an
14
+ * object ("fix sampler"), a path, another language — is a task of its own
15
+ * (fail-safe: its own selection, or unfiltered grounding). */
16
+ const CONTINUATION_WORDS = new Set([
17
+ "continue", "continuing", "go", "on", "ahead", "proceed", "resume", "carry", "keep", "going",
18
+ "yes", "yep", "yeah", "y", "ok", "okay", "sure", "please", "pls", "do", "it", "that", "this",
19
+ "next", "step", "again", "lgtm", "sounds", "good", "fine", "and", "the", "with", "now",
20
+ ]);
21
+ export function isBareFollowUp(prompt) {
22
+ const words = (prompt ?? "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
23
+ return words.length > 0 && words.every((word) => CONTINUATION_WORDS.has(word));
24
+ }
25
+ const BRACKET_PAIRS = { "(": ")", "[": "]", "{": "}" };
26
+ const CLOSING_BRACKETS = { ")": "(", "]": "[", "}": "{" };
27
+ /** Every `close` in text[from, to) follows its `open` (properly nested, none left open). */
28
+ function balanced(text, from, to, open, close) {
29
+ let depth = 0;
30
+ for (let i = from; i < to; i++) {
31
+ if (text[i] === open)
32
+ depth++;
33
+ else if (text[i] === close && --depth < 0)
34
+ return false;
35
+ }
36
+ return depth === 0;
37
+ }
38
+ /** Strip quoting, sentence punctuation, unmatched or wrapping brackets and a
39
+ * possessive `'s` from a prompt token until stable. A bracket the path itself
40
+ * uses (`(auth)/x.ts`, `[id]/page.tsx`, `src/app/(auth)`) is balanced and stays.
41
+ * Works on [a, b) indices with bracket counts kept current, so trimming a run of
42
+ * n unmatched brackets is linear, not n rescans of the token. */
43
+ const MAX_PATH_TOKEN_CHARS = 1024;
44
+ /** Wrapping pairs unwrapped per token; each unwrap rescans the token, and prose never nests deeper. */
45
+ const MAX_UNWRAPS = 8;
46
+ function trimPathToken(raw) {
47
+ const counts = { "(": 0, ")": 0, "[": 0, "]": 0, "{": 0, "}": 0 };
48
+ for (let i = 0; i < raw.length; i++)
49
+ if (raw[i] in counts)
50
+ counts[raw[i]]++;
51
+ const n = (ch) => counts[ch];
52
+ const dropAt = (i) => { if (raw[i] in counts)
53
+ counts[raw[i]]--; };
54
+ let a = 0;
55
+ let b = raw.length;
56
+ let unwraps = 0;
57
+ for (let before = -1; a < b && before !== b - a;) {
58
+ before = b - a;
59
+ // A trailing opening bracket can never close, so it goes first (keeps `[id]/x.ts[` from losing its head).
60
+ const lastOpen = raw[b - 1];
61
+ if (BRACKET_PAIRS[lastOpen] && n(lastOpen) > n(BRACKET_PAIRS[lastOpen]))
62
+ dropAt(--b);
63
+ if (a >= b)
64
+ break;
65
+ const head = raw[a];
66
+ const close = BRACKET_PAIRS[head];
67
+ const headOpen = CLOSING_BRACKETS[head];
68
+ if ("`'\"<\u2018\u201c".includes(head))
69
+ dropAt(a++);
70
+ else if (close && unwraps < MAX_UNWRAPS && b - a >= 2 && raw[b - 1] === close && n(head) === n(close) && balanced(raw, a + 1, b - 1, head, close)) {
71
+ unwraps++;
72
+ dropAt(a++);
73
+ dropAt(--b);
74
+ }
75
+ else if (close && n(head) > n(close))
76
+ dropAt(a++);
77
+ else if (headOpen && n(head) > n(headOpen))
78
+ dropAt(a++);
79
+ if (a >= b)
80
+ break;
81
+ const tail = raw[b - 1];
82
+ const open = CLOSING_BRACKETS[tail];
83
+ if ("`'\">,.;:!?\u2019\u201d".includes(tail))
84
+ dropAt(--b);
85
+ else if (open && n(open) < n(tail))
86
+ dropAt(--b);
87
+ if (b - a >= 2 && "sS".includes(raw[b - 1]) && "'\u2019".includes(raw[b - 2]))
88
+ b -= 2;
89
+ }
90
+ return raw.slice(a, b);
91
+ }
92
+ const MIN_DISTINCT_TERMS = 2;
93
+ /** Document-frequency cap, fixed before the pilot replay: a prompt term counts
94
+ * only when at most max(MIN_DF_CAP, ceil(DF_CAP_RATIO × live records)) records
95
+ * contain it. */
96
+ export const MIN_DF_CAP = 3;
97
+ export const DF_CAP_RATIO = 0.05;
98
+ const DEFAULT_K = 3;
99
+ const LIVE_FINDING_TRIAGE = new Set(["open", "accepted-risk", "scheduled"]);
100
+ /** The live slice the pre-edit grounding would ever deliver at HEAD: decisions and
101
+ * constraints still in force (the store's why() window plus delivery's retired
102
+ * test), every bug (why() keeps fixed ones as lessons), and findings whose triage
103
+ * liveFindingsFor() keeps. */
104
+ export function liveSelectionRecords(all) {
105
+ return {
106
+ decisions: all.decisions.filter((d) => d.status !== "rejected" && d.status !== "superseded" && !d.superseded_by && d.valid_to == null),
107
+ bugs: [...all.bugs],
108
+ constraints: all.constraints.filter((c) => c.status !== "retired" && c.valid_to == null),
109
+ findings: all.findings.filter((f) => LIVE_FINDING_TRIAGE.has(f.triage)),
110
+ };
111
+ }
112
+ /** Repo paths the prompt names: a markdown link `[label](target)` reads as its
113
+ * target, then a whitespace token containing `/` or ending in a file extension,
114
+ * stripped of quoting/punctuation/unmatched brackets, a trailing `:line[:col]`
115
+ * or `:start-end` and a possessive `'s`, forward-slashed, repo-relative. A drive-letter root compares
116
+ * case-insensitively (Windows paths are case-insensitive on every host). */
117
+ export function promptPaths(prompt, root) {
118
+ const out = new Set();
119
+ const rootPrefix = root ? `${root.replace(/\\/g, "/").replace(/\/+$/, "")}/` : null;
120
+ // Label and target may each hold one level of brackets (`[app/[id]/x.ts](app/[id]/x.ts)`,
121
+ // a linked `src/app/(auth)/page.tsx`); the label never spans a `[`, so a run of `[` fails fast.
122
+ const links = prompt.replace(/\[(?:[^[\]\n]|\[[^[\]\n]{0,256}\]){0,256}\]\(((?:[^()\s]|\([^()\s]{0,256}\)){1,1024})\)/g, " $1 ");
123
+ for (const raw of links.split(/\s+/)) {
124
+ // No repo path is this long; trimming pasted junk one bracket at a time would stall the prompt hook.
125
+ if (raw.length > MAX_PATH_TOKEN_CHARS)
126
+ continue;
127
+ let token = trimPathToken(raw).replace(/\\/g, "/").replace(/:\d+(?:[:-]\d+)?$/, "");
128
+ if (!token || /^[a-z][a-z0-9+.-]*:\/\//i.test(token))
129
+ continue;
130
+ if (!token.includes("/") && !/\.[a-z0-9]{1,8}$/i.test(token))
131
+ continue;
132
+ if (rootPrefix) {
133
+ const foldCase = process.platform === "win32" || /^[a-z]:\//i.test(token);
134
+ if (foldCase ? token.toLowerCase().startsWith(rootPrefix.toLowerCase()) : token.startsWith(rootPrefix))
135
+ token = token.slice(rootPrefix.length);
136
+ }
137
+ token = token.replace(/^(?:\.\/)+/, "");
138
+ // Absolute (outside the root) or parent-relative paths name nothing in this repo.
139
+ if (!token || token.startsWith("/") || /^[a-z]:/i.test(token) || token.split("/").includes(".."))
140
+ continue;
141
+ out.add(token);
142
+ }
143
+ return [...out];
144
+ }
145
+ /** Score live records against one prompt. Text fields per kind (title + rationale):
146
+ * decision title/context/decision; bug title/symptom/root_cause; constraint
147
+ * statement/rationale; finding title/observation. Path anchors: decision
148
+ * related_files, bug/finding affected_files, constraint scope globs. Priority
149
+ * ties break on the builder profile's kind/severity score in delivery.ts. */
150
+ export function selectForTask(records, promptText, opts = {}) {
151
+ const k = Math.max(0, opts.k ?? DEFAULT_K);
152
+ const taskTerms = lexicalTokens(promptText ?? "");
153
+ // Without an existence check (pure/offline) only a slashed token is trusted as a
154
+ // path: "e.g.", "Node.js" or "v1.2" would otherwise match a broad glob.
155
+ const paths = promptPaths(promptText ?? "", opts.root).filter((p) => opts.pathExists ? opts.pathExists(p) : p.includes("/"));
156
+ if (!taskTerms.size && !paths.length)
157
+ return { qualifying: [], top: [] };
158
+ const base = PROFILE_BASE_SCORE.builder;
159
+ // Tokenize every record once: the same tokens feed the document frequency and the score.
160
+ const candidates = [];
161
+ const consider = (id, kind, title, text, anchors, priority, listable) => {
162
+ candidates.push({ id, kind, title, tokens: lexicalTokens(text.join(" ")), anchors, priority, listable });
163
+ };
164
+ for (const c of records.constraints) {
165
+ consider(c.id, "constraint", c.statement, [c.statement, c.rationale], c.scope, base.constraints + SEVERITY[c.severity] * 10 + (c.provenance.confidence ?? 0), c.severity !== "blocking");
166
+ }
167
+ for (const d of records.decisions) {
168
+ consider(d.id, "decision", d.title, [d.title, d.context, d.decision], d.related_files, base.decisions + (d.status === "accepted" ? 20 : 0) + (d.provenance.confidence ?? 0), true);
169
+ }
170
+ for (const b of records.bugs) {
171
+ consider(b.id, "bug", b.title, [b.title, b.symptom, b.root_cause], b.affected_files, base.bugs + SEVERITY[b.severity] * 10 + (b.status === "open" || b.status === "regressed" ? 10 : 0), true);
172
+ }
173
+ for (const f of records.findings) {
174
+ consider(f.id, "finding", f.title, [f.title, f.observation], f.affected_files, base.findings + SEVERITY[f.severity] * 10, true);
175
+ }
176
+ const dfCap = Math.max(MIN_DF_CAP, Math.ceil(DF_CAP_RATIO * candidates.length));
177
+ const rareTerms = [...taskTerms].filter((term) => candidates.filter((c) => c.tokens.has(term)).length <= dfCap);
178
+ const scored = [];
179
+ for (const { tokens, anchors, ...rest } of candidates) {
180
+ let terms = 0;
181
+ for (const term of rareTerms)
182
+ if (tokens.has(term))
183
+ terms++;
184
+ const pathHits = paths.filter((p) => anchors.some((anchor) => pathMatchesGlob(p, anchor))).length;
185
+ if (pathHits > 0 || terms >= MIN_DISTINCT_TERMS)
186
+ scored.push({ ...rest, pathHits, terms });
187
+ }
188
+ // A record anchored to more of the files the prompt names outranks one that
189
+ // shares a single (often hub) file with it; rare terms break the remaining ties.
190
+ scored.sort((a, b) => b.pathHits - a.pathHits || b.terms - a.terms || b.priority - a.priority || a.id.localeCompare(b.id));
191
+ return {
192
+ qualifying: scored.map((s) => s.id),
193
+ top: scored.filter((s) => s.listable).slice(0, k).map(({ id, kind, title }) => ({ id, kind, title })),
194
+ };
195
+ }
196
+ //# sourceMappingURL=taskSelection.js.map
@@ -1,6 +1,8 @@
1
1
  /** Files Hunch regenerates on every capture (src/integrations/providers.ts,
2
2
  * claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
3
3
  export declare const HUNCH_MANAGED_FILES: ReadonlySet<string>;
4
+ /** Commit subjects Hunch writes itself. */
5
+ export declare const HUNCH_COMMIT_SUBJECT: RegExp;
4
6
  export declare function gitTouchedFiles(root: string, startedAt: string, finishedAt: string | null, options?: {
5
7
  limit?: number;
6
8
  timeoutMs?: number;
@@ -27,7 +27,7 @@ const EXCLUDED_SEGMENTS = new Set([".hunch", ".hunch-cache", ".git"]);
27
27
  * claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
28
28
  export const HUNCH_MANAGED_FILES = new Set(["CLAUDE.md", "AGENTS.md", ".cursor/rules/hunch.mdc", ".github/copilot-instructions.md", ".windsurf/rules/hunch.md"]);
29
29
  /** Commit subjects Hunch writes itself. */
30
- const HUNCH_COMMIT_SUBJECT = /^hunch:/i;
30
+ export const HUNCH_COMMIT_SUBJECT = /^hunch:/i;
31
31
  function gitDate(ms) {
32
32
  // Second resolution, a format every git accepts.
33
33
  return `${new Date(ms).toISOString().slice(0, 19).replace("T", " ")} +0000`;
@@ -125,7 +125,11 @@ export declare function headFileContent(root: string, rel: string): string | nul
125
125
  * machines' memory. Never throws; leaves a clean tree. Serialized with the commit lock so it
126
126
  * can't race a concurrent flush. Returns true only when the checked-out memory HEAD moved,
127
127
  * allowing callers to avoid rebuilding the derived SQLite index after a no-op pull. */
128
- export type HunchPullStatus = "updated" | "current" | "busy" | "failed" | "unconfigured";
128
+ export type HunchPullStatus = "updated" | "current" | "busy" | "failed" | "unconfigured" | "empty";
129
+ /** A strict guard may pass only on a pull that proved this clone's memory matches the
130
+ * advertised remote: it advanced, was already current, or both are provably empty (a
131
+ * brand-new shared memory repo before its first capture, #427). */
132
+ export declare function teamPullIsFresh(status: HunchPullStatus): boolean;
129
133
  export interface PullHunchOptions {
130
134
  timeoutMs?: number;
131
135
  env?: NodeJS.ProcessEnv;
@@ -298,6 +302,17 @@ export type CommitRepairStatus = "orphaned" | "current" | "unresolvable" | "unkn
298
302
  * be treated as repair-eligible; collapsing "false" and "error" into one
299
303
  * boolean is exactly the bug this type exists to prevent. */
300
304
  export declare function commitRepairStatus(commit: string, ref: string, cwd: string): CommitRepairStatus;
305
+ /** commitRepairStatus against several tips: "orphaned" only when NO ref
306
+ * reaches the commit. A historical `--range old..new` must not make a commit
307
+ * that HEAD still reaches look orphaned just because it is newer than `new`
308
+ * (#450). The first non-orphaned answer wins, so "unknown" is never upgraded. */
309
+ export declare function commitRepairStatusAgainst(commit: string, refs: readonly string[], cwd: string): CommitRepairStatus;
310
+ /** `git patch-id --stable` of each commit, keyed by the sha exactly as given
311
+ * (full or abbreviated). Same diff flags as the workspace ledger's squash
312
+ * check. A merge or empty commit, an unknown sha, or any git failure leaves
313
+ * no entry: a missing patch-id is never a match. One `git log` + one
314
+ * `git patch-id` process for all of them. */
315
+ export declare function commitPatchIds(shas: readonly string[], cwd: string): Map<string, string>;
301
316
  /** Byte budget for a diff embedded in a SYNTHESIS prompt. Gates never use it: a
302
317
  * constraint check over a prefix of a change would read every file past the
303
318
  * cutoff as "no added lines" and pass it. */
@@ -328,8 +343,19 @@ export declare function lastCommitForFile(file: string, cwd: string): string;
328
343
  /** Full SHA of the commit that introduced a path. Unlike lastCommitForFile this
329
344
  * remains stable when lifecycle/proof updates later touch the same policy file. */
330
345
  export declare function firstCommitForFile(file: string, cwd: string): string;
331
- /** ISO author-date of the most recent commit touching a file ("" if none). */
332
- export declare function lastChangeDate(file: string, cwd: string): string;
346
+ /** ISO author-date of the most recent commit touching a file ("" if none), as of `rev`
347
+ * (default HEAD). */
348
+ export declare function lastChangeDate(file: string, cwd: string, rev?: string): string;
349
+ /** The revision a checked change starts from, so freshness is judged WITHOUT the change
350
+ * itself: a committed change under review must not make its own guarding record stale
351
+ * (#426). `--base` diffs are 3-dot, so the merge-base; `--commit` is its parent;
352
+ * staged/working changes are not committed yet, so HEAD (undefined). `--commit` judges
353
+ * one commit in isolation, so an earlier commit of the same branch can still age the
354
+ * record; `--base` (the CI guard) judges the whole branch and is the authoritative gate. */
355
+ export declare function checkFreshnessRevision(source: {
356
+ base?: string;
357
+ commit?: string;
358
+ }, cwd: string): string | undefined;
333
359
  /**
334
360
  * Newest author date for every changed path selected by a bounded set of repository-relative
335
361
  * Hunch scopes. One Git history walk replaces the per-file process loop used by `staleness()`.
@@ -370,9 +396,13 @@ export declare function revExists(ref: string, cwd: string): boolean;
370
396
  /** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
371
397
  * i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
372
398
  export declare function rangeFiles(base: string, cwd: string, head?: string): string[];
373
- /** Commit subjects on `head` since `base` (2-dot: commits added by the task),
374
- * oldest-first, for distilling a runbook's ordered steps (roadmap #5). */
375
- export declare function rangeSubjects(base: string, cwd: string, head?: string, max?: number): string[];
399
+ /** Commits on `head` since `base` (2-dot: commits added by the task), oldest-first, each
400
+ * with its subject and the files it changed, for distilling a runbook's ordered steps
401
+ * (roadmap #5); `runbookDraft` uses the files to tell Hunch's own captures from the work. */
402
+ export declare function rangeCommits(base: string, cwd: string, head?: string, max?: number): {
403
+ subject: string;
404
+ files: string[];
405
+ }[];
376
406
  /** Complete gating diff of a PR vs `base` (3-dot: the CI Constraint Guard's surface). */
377
407
  export declare function rangeGateDiff(base: string, cwd: string, head?: string): GateDiff;
378
408
  /** The PR's unified diff vs `base` (3-dot), noise-excluded and capped at the