@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.
- package/README.md +14 -0
- package/dist/cli/index.js +206 -37
- package/dist/core/changeProof.js +2 -1
- package/dist/core/commitrepair.d.ts +9 -3
- package/dist/core/commitrepair.js +23 -6
- package/dist/core/correction.d.ts +1 -1
- package/dist/core/correction.js +4 -3
- package/dist/core/delivery.d.ts +12 -0
- package/dist/core/delivery.js +4 -4
- package/dist/core/docanchors.d.ts +11 -2
- package/dist/core/docanchors.js +232 -65
- package/dist/core/drift.d.ts +1 -1
- package/dist/core/drift.js +10 -3
- package/dist/core/eol.d.ts +2 -0
- package/dist/core/eol.js +6 -0
- package/dist/core/hookcache.d.ts +22 -0
- package/dist/core/hookcache.js +53 -2
- package/dist/core/procstart.d.ts +15 -4
- package/dist/core/procstart.js +56 -18
- package/dist/core/publication.js +17 -3
- package/dist/core/runbookDraft.d.ts +7 -0
- package/dist/core/runbookDraft.js +43 -0
- package/dist/core/taskReportHook.js +1 -1
- package/dist/core/taskSelection.d.ts +67 -0
- package/dist/core/taskSelection.js +196 -0
- package/dist/core/taskTouched.d.ts +2 -0
- package/dist/core/taskTouched.js +1 -1
- package/dist/extractors/git.d.ts +36 -6
- package/dist/extractors/git.js +86 -9
- package/dist/extractors/indexer.js +53 -14
- package/dist/extractors/k8sManifest.d.ts +31 -1
- package/dist/extractors/k8sManifest.js +133 -8
- package/dist/integrations/claudemd.d.ts +2 -0
- package/dist/integrations/claudemd.js +4 -2
- package/dist/integrations/gitignore.d.ts +7 -0
- package/dist/integrations/gitignore.js +18 -0
- package/dist/integrations/hooks.d.ts +15 -0
- package/dist/integrations/hooks.js +49 -1
- package/dist/integrations/mergeDriver.d.ts +2 -0
- package/dist/integrations/mergeDriver.js +2 -2
- package/dist/integrations/providers.d.ts +7 -0
- package/dist/integrations/providers.js +6 -5
- package/dist/integrations/scaffold.d.ts +5 -0
- package/dist/integrations/scaffold.js +14 -8
- package/dist/integrations/uninstall.d.ts +40 -0
- package/dist/integrations/uninstall.js +435 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +58 -4
- package/dist/mcp/taskReportTools.js +1 -1
- package/dist/serve/writelock.d.ts +7 -0
- package/dist/serve/writelock.js +30 -3
- package/dist/store/changeLedger.d.ts +5 -2
- package/dist/store/changeLedger.js +88 -37
- package/dist/store/hunchStore.js +4 -1
- package/package.json +3 -3
- package/server.json +2 -2
package/dist/core/procstart.js
CHANGED
|
@@ -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
|
-
*
|
|
27
|
-
* (`/proc/<pid>/stat` field 22 plus the boot id and the pid/time namespaces)
|
|
28
|
-
* macOS and Windows a DIFFERENT live process that recycled the owner's pid
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
55
|
-
*
|
|
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
|
|
241
|
-
//
|
|
242
|
-
|
|
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
|
package/dist/core/publication.js
CHANGED
|
@@ -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]
|
|
38
|
-
/(?:^|[
|
|
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,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 ?? ""}.
|
|
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;
|
package/dist/core/taskTouched.js
CHANGED
|
@@ -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`;
|
package/dist/extractors/git.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
374
|
-
*
|
|
375
|
-
|
|
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
|