@davesheffer/hunch 1.45.2 → 1.47.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 +290 -353
- package/dist/cli/invocation.d.ts +22 -13
- package/dist/cli/invocation.js +49 -29
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/agenthook.js +26 -6
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/correction.js +9 -9
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- package/dist/core/docanchors.js +9 -9
- package/dist/core/drift.js +22 -27
- package/dist/core/escalations.d.ts +16 -58
- package/dist/core/escalations.js +39 -121
- package/dist/core/glob.d.ts +7 -7
- package/dist/core/glob.js +12 -11
- package/dist/core/groundingLag.d.ts +2 -9
- package/dist/core/groundingLag.js +5 -11
- package/dist/core/groundingMerge.d.ts +2 -7
- package/dist/core/groundingMerge.js +35 -54
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/projectDna.js +11 -1
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/extractors/diff.d.ts +4 -4
- package/dist/extractors/diff.js +25 -29
- package/dist/extractors/git.d.ts +8 -23
- package/dist/extractors/git.js +77 -95
- package/dist/extractors/helm.d.ts +10 -0
- package/dist/extractors/helm.js +43 -59
- package/dist/extractors/indexer.d.ts +6 -0
- package/dist/extractors/indexer.js +168 -184
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +37 -37
- package/dist/extractors/languages.js +83 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +27 -13
- package/dist/extractors/parse.js +120 -56
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +13 -16
- package/dist/integrations/hooks.js +31 -30
- package/dist/integrations/mergeDriver.js +18 -17
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +4 -2
- package/dist/integrations/uninstall.d.ts +10 -4
- package/dist/integrations/uninstall.js +22 -18
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +107 -81
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +3 -1
- package/dist/serve/writelock.js +17 -3
- package/dist/store/hunchStore.d.ts +2 -1
- package/dist/store/hunchStore.js +20 -23
- package/dist/store/jsonStore.d.ts +13 -3
- package/dist/store/jsonStore.js +37 -13
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +93 -154
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +33 -50
- package/package.json +3 -3
- package/server.json +2 -2
package/dist/core/migrate.js
CHANGED
|
@@ -31,6 +31,7 @@ export const MIGRATIONS = [
|
|
|
31
31
|
// losslessly — no record is dropped, and `valid_from` is populated BEFORE the
|
|
32
32
|
// Zod pass. Defensive: input is untrusted JSON.
|
|
33
33
|
version: 2,
|
|
34
|
+
kinds: ["decisions", "constraints"],
|
|
34
35
|
description: "Add valid_from/valid_to/superseded_by/retired (decisions) and status/valid_from/valid_to (constraints)",
|
|
35
36
|
up(kind, raw) {
|
|
36
37
|
if (kind === "decisions") {
|
|
@@ -64,6 +65,7 @@ export const MIGRATIONS = [
|
|
|
64
65
|
// relationship schema is validated; newly written resource relationships use
|
|
65
66
|
// hunch.resource-relationship/1 and keep their currentness/metadata.
|
|
66
67
|
version: 3,
|
|
68
|
+
kinds: ["edges"],
|
|
67
69
|
description: "Version existing graph edges before adding Engineering Landscape resources and relationships",
|
|
68
70
|
up(kind, raw) {
|
|
69
71
|
if (kind === "edges") {
|
|
@@ -98,15 +100,24 @@ export function writeManifest(paths, version = SCHEMA_VERSION) {
|
|
|
98
100
|
writeFileAtomic(paths.manifest, JSON.stringify({ schema_version: version }, null, 2) + "\n");
|
|
99
101
|
}
|
|
100
102
|
/** Apply every migration in (fromVersion, toVersion] to a single raw record. Skips
|
|
101
|
-
* non-object input untouched (the loader's Zod pass will reject it).
|
|
103
|
+
* non-object input untouched (the loader's Zod pass will reject it). All-or-nothing:
|
|
104
|
+
* the steps run on a deep copy, so `raw` is never mutated and a caller that rejects
|
|
105
|
+
* the result (or a step that throws) keeps the original record intact — never a
|
|
106
|
+
* half-migrated one. */
|
|
102
107
|
export function migrateRaw(kind, raw, fromVersion, migrations = MIGRATIONS, toVersion = SCHEMA_VERSION) {
|
|
103
108
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
104
109
|
return raw;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
const steps = migrations.filter((m) => m.version > fromVersion && m.version <= toVersion);
|
|
111
|
+
if (steps.length === 0)
|
|
112
|
+
return raw; // nothing to apply: no copy needed
|
|
113
|
+
let rec = structuredClone(raw);
|
|
114
|
+
for (const m of steps.sort((a, b) => a.version - b.version))
|
|
115
|
+
rec = m.up(kind, rec);
|
|
110
116
|
return rec;
|
|
111
117
|
}
|
|
118
|
+
/** Whether some migration in (fromVersion, toVersion] may change a record of
|
|
119
|
+
* `kind` — i.e. whether re-running migrations could ever fix a failed one. */
|
|
120
|
+
export function hasPendingMigration(kind, fromVersion, migrations = MIGRATIONS, toVersion = SCHEMA_VERSION) {
|
|
121
|
+
return migrations.some((m) => m.version > fromVersion && m.version <= toVersion && (!m.kinds || m.kinds.includes(kind)));
|
|
122
|
+
}
|
|
112
123
|
//# sourceMappingURL=migrate.js.map
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
export declare const HUNCH_DIR = ".hunch";
|
|
2
|
-
/**
|
|
3
|
-
* path with "/"
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* repo-relative — an absolute path passes through with its separators flipped,
|
|
2
|
+
/** Convert a user- or agent-supplied path/target to forward-slash form. Hunch
|
|
3
|
+
* stores every path with "/" (git emits it on every OS), so a Windows-style
|
|
4
|
+
* backslash path would otherwise never match the stored form. Harmless on
|
|
5
|
+
* symbol names, which carry no backslashes. It does NOT make a path
|
|
6
|
+
* repo-relative: an absolute path only has its separators flipped and is left
|
|
8
7
|
* unchanged otherwise; use `repoRelativeTarget` for that.
|
|
9
8
|
*
|
|
10
9
|
* Inherently ambiguous for a string like `docs/notes\notes.md`: it could be a
|
|
@@ -15,25 +14,19 @@ export declare const HUNCH_DIR = ".hunch";
|
|
|
15
14
|
* needs the correct answer for a real file should decide from that evidence
|
|
16
15
|
* instead of trusting this blindly. */
|
|
17
16
|
export declare function toPosixTarget(target: string): string;
|
|
18
|
-
/**
|
|
19
|
-
* to
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
/** Canonical real path of `p`, tolerant of paths that do not exist yet (a file
|
|
18
|
+
* about to be written, a glob with non-literal segments): the longest existing
|
|
19
|
+
* ancestor is resolved through the native realpath and the missing trailing
|
|
20
|
+
* segments are re-appended unchanged. Terminates at the filesystem root,
|
|
21
|
+
* is idempotent on a resolved path and never throws for a missing one. */
|
|
22
22
|
export declare function realpathNorm(p: string): string;
|
|
23
|
-
/** Rewrite an ABSOLUTE target to repo-relative
|
|
24
|
-
* `root`. Hunch
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* BOTH ends are realpath-normalized before the relative-path computation: on
|
|
32
|
-
* macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
|
|
33
|
-
* hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
|
|
34
|
-
* bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
|
|
35
|
-
* caller treats the file as outside the repo, silently dropping all context
|
|
36
|
-
* (dec_e0a36efbf5). */
|
|
23
|
+
/** Rewrite an ABSOLUTE `target` to repo-relative forward-slash form when it lies
|
|
24
|
+
* inside `root`. Stored Hunch paths are always repo-relative, so an absolute
|
|
25
|
+
* path lifted from an edit payload otherwise matches nothing (issue #296).
|
|
26
|
+
* Both ends go through `realpathNorm`, so a symlinked root (macOS /var vs
|
|
27
|
+
* /private/var, /tmp, a symlinked home) cancels out (dec_e0a36efbf5). Anything
|
|
28
|
+
* with no safe repo-relative form (the root itself, an escape, a drive-letter
|
|
29
|
+
* remainder) is returned in its normalized form unchanged. */
|
|
37
30
|
export declare function repoRelativeTarget(target: string, root: string): string;
|
|
38
31
|
/** True when `target` (already repo-relative POSIX form) names a regular FILE
|
|
39
32
|
* that really exists inside `root`. The LAST-RESORT half of the "is this a real
|
package/dist/core/paths.js
CHANGED
|
@@ -3,12 +3,11 @@ import { join } from "node:path";
|
|
|
3
3
|
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
5
|
export const HUNCH_DIR = ".hunch";
|
|
6
|
-
/**
|
|
7
|
-
* path with "/"
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* repo-relative — an absolute path passes through with its separators flipped,
|
|
6
|
+
/** Convert a user- or agent-supplied path/target to forward-slash form. Hunch
|
|
7
|
+
* stores every path with "/" (git emits it on every OS), so a Windows-style
|
|
8
|
+
* backslash path would otherwise never match the stored form. Harmless on
|
|
9
|
+
* symbol names, which carry no backslashes. It does NOT make a path
|
|
10
|
+
* repo-relative: an absolute path only has its separators flipped and is left
|
|
12
11
|
* unchanged otherwise; use `repoRelativeTarget` for that.
|
|
13
12
|
*
|
|
14
13
|
* Inherently ambiguous for a string like `docs/notes\notes.md`: it could be a
|
|
@@ -21,47 +20,42 @@ export const HUNCH_DIR = ".hunch";
|
|
|
21
20
|
export function toPosixTarget(target) {
|
|
22
21
|
return target.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
23
22
|
}
|
|
24
|
-
/**
|
|
25
|
-
* to
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
/** Canonical real path of `p`, tolerant of paths that do not exist yet (a file
|
|
24
|
+
* about to be written, a glob with non-literal segments): the longest existing
|
|
25
|
+
* ancestor is resolved through the native realpath and the missing trailing
|
|
26
|
+
* segments are re-appended unchanged. Terminates at the filesystem root,
|
|
27
|
+
* is idempotent on a resolved path and never throws for a missing one. */
|
|
28
28
|
export function realpathNorm(p) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
const tail = [];
|
|
30
|
+
let cur = p;
|
|
31
|
+
for (;;) {
|
|
32
|
+
try {
|
|
33
|
+
const real = realpathSync.native(cur);
|
|
34
|
+
return tail.length ? join(real, ...tail.reverse()) : real;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
const parent = dirname(cur);
|
|
38
|
+
if (parent === cur)
|
|
39
|
+
return p;
|
|
40
|
+
tail.push(basename(cur));
|
|
41
|
+
cur = parent;
|
|
42
|
+
}
|
|
37
43
|
}
|
|
38
44
|
}
|
|
39
|
-
/** Rewrite an ABSOLUTE target to repo-relative
|
|
40
|
-
* `root`. Hunch
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* BOTH ends are realpath-normalized before the relative-path computation: on
|
|
48
|
-
* macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
|
|
49
|
-
* hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
|
|
50
|
-
* bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
|
|
51
|
-
* caller treats the file as outside the repo, silently dropping all context
|
|
52
|
-
* (dec_e0a36efbf5). */
|
|
45
|
+
/** Rewrite an ABSOLUTE `target` to repo-relative forward-slash form when it lies
|
|
46
|
+
* inside `root`. Stored Hunch paths are always repo-relative, so an absolute
|
|
47
|
+
* path lifted from an edit payload otherwise matches nothing (issue #296).
|
|
48
|
+
* Both ends go through `realpathNorm`, so a symlinked root (macOS /var vs
|
|
49
|
+
* /private/var, /tmp, a symlinked home) cancels out (dec_e0a36efbf5). Anything
|
|
50
|
+
* with no safe repo-relative form (the root itself, an escape, a drive-letter
|
|
51
|
+
* remainder) is returned in its normalized form unchanged. */
|
|
53
52
|
export function repoRelativeTarget(target, root) {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// so an un-posix'd root and a posix'd target would decompose into differently-shaped
|
|
61
|
-
// ancestor chains and never share a common realpath'd prefix.
|
|
62
|
-
const rel = toPosixTarget(relative(realpathNorm(toPosixTarget(root)), realpathNorm(t)));
|
|
63
|
-
if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
|
|
64
|
-
return t;
|
|
53
|
+
const norm = toPosixTarget(target);
|
|
54
|
+
if (!isAbsolute(norm) && !/^[A-Za-z]:/.test(norm))
|
|
55
|
+
return norm;
|
|
56
|
+
const rel = relative(realpathNorm(toPosixTarget(root)), realpathNorm(norm)).replace(/\\/g, "/");
|
|
57
|
+
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[A-Za-z]:/.test(rel))
|
|
58
|
+
return norm;
|
|
65
59
|
return rel;
|
|
66
60
|
}
|
|
67
61
|
/** True when `target` (already repo-relative POSIX form) names a regular FILE
|
package/dist/core/projectDna.js
CHANGED
|
@@ -81,7 +81,17 @@ function gitText(root, args) {
|
|
|
81
81
|
function exactCommit(root, ref) {
|
|
82
82
|
if (!ref.trim() || /[\0\r\n]/.test(ref) || ref.length > 1_024)
|
|
83
83
|
throw new Error("Git revision is invalid");
|
|
84
|
-
|
|
84
|
+
let revision;
|
|
85
|
+
try {
|
|
86
|
+
revision = gitText(root, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
// Git's own text ("fatal: Needed a single revision") never names the ref (#493);
|
|
90
|
+
// any other failure keeps its original message.
|
|
91
|
+
if (!/Needed a single revision|unknown revision|bad revision/i.test(error.message))
|
|
92
|
+
throw error;
|
|
93
|
+
throw new Error(`Git revision ${JSON.stringify(ref)} does not name a commit in this repository`);
|
|
94
|
+
}
|
|
85
95
|
if (!GIT_OBJECT.test(revision))
|
|
86
96
|
throw new Error("Git did not return an exact commit object");
|
|
87
97
|
return revision;
|
package/dist/core/refrepair.d.ts
CHANGED
|
@@ -12,10 +12,11 @@ export interface DecisionReferenceRepair {
|
|
|
12
12
|
relatedFiles: number;
|
|
13
13
|
evidence: number;
|
|
14
14
|
}
|
|
15
|
-
export
|
|
15
|
+
export type ExactReplacement = {
|
|
16
16
|
values: string[];
|
|
17
17
|
changed: number;
|
|
18
18
|
};
|
|
19
|
+
export declare function replaceExact(refs: string[], target: string, replacement: string): ExactReplacement;
|
|
19
20
|
/**
|
|
20
21
|
* Return a corrected copy of a decision, or `null` when the source reference is
|
|
21
22
|
* not present. The decision's semantic content and verification timestamp are
|
package/dist/core/refrepair.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export function replaceExact(
|
|
1
|
+
export function replaceExact(refs, target, replacement) {
|
|
2
2
|
let changed = 0;
|
|
3
|
-
const replaced =
|
|
4
|
-
if (value !==
|
|
3
|
+
const replaced = refs.map((value) => {
|
|
4
|
+
if (value !== target)
|
|
5
5
|
return value;
|
|
6
6
|
changed++;
|
|
7
|
-
return
|
|
7
|
+
return replacement;
|
|
8
8
|
});
|
|
9
9
|
// A decision may already cite the destination. Keep the reference list a set
|
|
10
10
|
// after the repair so a correction cannot create duplicate scope/evidence.
|
|
@@ -26,8 +26,12 @@ export function relativeImportCandidates(fromFile, specifier) {
|
|
|
26
26
|
* indexer compatibility; callers that need ambiguity metadata can inspect the
|
|
27
27
|
* returned matches instead of guessing a different target. */
|
|
28
28
|
export function resolveRelativeImport(fromFile, specifier, availableFiles) {
|
|
29
|
-
|
|
30
|
-
const
|
|
29
|
+
// Bare/non-relative specifiers have no candidates: skip the O(N) file-set copy.
|
|
30
|
+
const candidates = relativeImportCandidates(fromFile, specifier);
|
|
31
|
+
if (candidates.length === 0)
|
|
32
|
+
return { path: null, matches: [] };
|
|
33
|
+
const available = availableFiles instanceof Set ? availableFiles : new Set(availableFiles);
|
|
34
|
+
const matches = candidates.filter((candidate) => available.has(candidate));
|
|
31
35
|
return { path: matches[0] ?? null, matches };
|
|
32
36
|
}
|
|
33
37
|
//# sourceMappingURL=relativeImports.js.map
|
|
@@ -1,40 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
/**
|
|
3
|
-
* is local scratch state, never a source of truth worth failing loudly over. */
|
|
1
|
+
import type { CommitRewrite, DroppedRewrite } from "./commitrepair.js";
|
|
2
|
+
/** The raw queue. Missing, corrupt or non-array files read as `[]`; never throws. */
|
|
4
3
|
export declare function readPendingRepairs(root: string): CommitRewrite[];
|
|
4
|
+
/** Atomically replace the queue. Writing `[]` clears it. */
|
|
5
5
|
export declare function writePendingRepairs(root: string, rewrites: readonly CommitRewrite[]): void;
|
|
6
|
-
/**
|
|
6
|
+
/** The tombstones of rewrites rejected with `--drop`. Same tolerance as the queue. */
|
|
7
7
|
export declare function readDroppedRepairs(root: string): DroppedRewrite[];
|
|
8
|
+
/** Atomically replace the tombstone file. Writing `[]` clears it. */
|
|
8
9
|
export declare function writeDroppedRepairs(root: string, dropped: readonly DroppedRewrite[]): void;
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
* `repair-provenance`'s own action is the one exception: it needs the RAW
|
|
16
|
-
* read (via readPendingRepairs) because it diffs raw-vs-swept to report what
|
|
17
|
-
* it cleaned up, and it persists the sweep back to disk. Every other
|
|
18
|
-
* reader — `hunch escalations`, SessionStart orientation, the `hunch_now`
|
|
19
|
-
* and `hunch_escalations` MCP tools — must call this instead, or a
|
|
20
|
-
* tombstoned entry that hasn't yet been swept by a `repair-provenance` run
|
|
21
|
-
* re-surfaces as if the human never answered it. */
|
|
10
|
+
/**
|
|
11
|
+
* The raw queue minus anything tombstoned. Read-only: it never changes either
|
|
12
|
+
* file. Every read-only consumer uses this -- `hunch escalations`, SessionStart
|
|
13
|
+
* orientation, and the `hunch_now` and `hunch_escalations` MCP tools. Only
|
|
14
|
+
* `repair-provenance` itself reads the raw queue.
|
|
15
|
+
*/
|
|
22
16
|
export declare function readActivePendingRepairs(root: string): CommitRewrite[];
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
* `--apply --only <id>` that's guaranteed to no-op forever. `null` from
|
|
30
|
-
* commitsExist (the check itself failed to run) is treated as fail-open —
|
|
31
|
-
* same discipline as the apply path — so nothing is misreported as
|
|
32
|
-
* withheld just because the check couldn't run.
|
|
33
|
-
*
|
|
34
|
-
* Returns the OBJECTS, not their ids, and every caller must pass the exact
|
|
35
|
-
* same `queued` array into both this function and whatever it hands the
|
|
36
|
-
* result to (commitRepairEscalations) — an id-keyed set would collapse a
|
|
37
|
-
* corrupted queue's two same-id entries (one resolvable, one not) into one
|
|
38
|
-
* unit, exactly the identity confusion withheldForUnresolvableTo's own
|
|
39
|
-
* docstring warns about. */
|
|
17
|
+
/**
|
|
18
|
+
* The queued entries whose proposed `to` does not resolve in this repository.
|
|
19
|
+
* Fails open (nothing withheld) when git cannot answer. The set holds the
|
|
20
|
+
* caller's own objects, so callers must pass the same `queued` array here and to
|
|
21
|
+
* whatever consumes the result.
|
|
22
|
+
*/
|
|
40
23
|
export declare function withheldRewrites(root: string, queued: readonly CommitRewrite[]): Set<CommitRewrite>;
|
package/dist/core/repairqueue.js
CHANGED
|
@@ -1,131 +1,81 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local-only queue of commit-provenance repairs
|
|
3
|
-
* (typically by the post-merge hook, src/integrations/hooks.ts) but not yet
|
|
4
|
-
* applied. `.hunch/pending-commit-repairs.json` is gitignored the same way
|
|
5
|
-
* `.hunch/local.json` is — never committed, never pushed. It exists because
|
|
6
|
-
* the opportunistic detection window is narrow: `ORIG_HEAD` gets overwritten
|
|
7
|
-
* by the next merge, and the matched-away commit can eventually be gc'd —
|
|
8
|
-
* queuing the exact {id, from, to} triple here lets a human confirm the match
|
|
9
|
-
* later (`hunch repair-provenance --apply`) without losing it.
|
|
2
|
+
* Local-only queue of detected-but-not-yet-applied commit-provenance repairs.
|
|
10
3
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
4
|
+
* It is filled opportunistically, usually by the post-merge hook, and is
|
|
5
|
+
* gitignored like `.hunch/local.json` -- never committed. It exists because the
|
|
6
|
+
* detection window is narrow: `ORIG_HEAD` gets overwritten and the matched-away
|
|
7
|
+
* commit can be garbage-collected. A human confirms later with
|
|
8
|
+
* `hunch repair-provenance --apply`. A sibling tombstone file records the exact
|
|
9
|
+
* `{id, from, to}` triples rejected with `--drop`, so re-detection does not
|
|
10
|
+
* re-queue them.
|
|
18
11
|
*/
|
|
19
12
|
import { existsSync, readFileSync } from "node:fs";
|
|
20
13
|
import { join } from "node:path";
|
|
21
14
|
import { writeFileAtomic } from "./io.js";
|
|
22
15
|
import { withoutDropped, withheldForUnresolvableTo } from "./commitrepair.js";
|
|
23
16
|
import { commitsExist } from "../extractors/git.js";
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
17
|
+
const queuePath = (root) => join(root, ".hunch", "pending-commit-repairs.json");
|
|
18
|
+
const droppedPath = (root) => join(root, ".hunch", "dropped-commit-repairs.json");
|
|
19
|
+
const nonEmpty = (v) => typeof v === "string" && v.length > 0;
|
|
20
|
+
/**
|
|
21
|
+
* Shape-only validation: non-empty string id/from/to, and `to !== from` (a no-op
|
|
22
|
+
* is rejected). There is deliberately no hex check -- a bad `to` is caught later
|
|
23
|
+
* by the apply-time existence check, so this loader stays shape-only.
|
|
24
|
+
*/
|
|
25
|
+
function isRewrite(v) {
|
|
26
|
+
if (typeof v !== "object" || v === null)
|
|
32
27
|
return false;
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
// and a no-op entry would apply "successfully" while accomplishing nothing.
|
|
36
|
-
// Deliberately NOT a hex-shape check: from/to are opaque strings elsewhere
|
|
37
|
-
// in this codebase's test fixtures, and repairDecisionCommit's own
|
|
38
|
-
// `d.commit === mine.from` guard already makes a garbage `from` inert (it
|
|
39
|
-
// can never match a real decision). A garbage `to` is caught elsewhere,
|
|
40
|
-
// not here: `hunch repair-provenance --apply` runs commitsExist against
|
|
41
|
-
// every `to` it's actually about to act on (the whole queue, or just the
|
|
42
|
-
// one entry `--only <id>` targets) before repairDecisionCommit ever sees
|
|
43
|
-
// it, and leaves a non-resolving entry queued rather than applying it
|
|
44
|
-
// (withheldForUnresolvableTo, src/core/commitrepair.ts) — with the caveat
|
|
45
|
-
// that commitsExist itself fails OPEN if the check can't run at all (not a
|
|
46
|
-
// git repo, git missing, timeout), same as drift.ts's own use of it. This
|
|
47
|
-
// filter stays shape-only on purpose — the existence check needs a
|
|
48
|
-
// repository to run against, which this read-only queue loader doesn't
|
|
49
|
-
// have.
|
|
50
|
-
return typeof id === "string" && !!id
|
|
51
|
-
&& typeof from === "string" && !!from
|
|
52
|
-
&& typeof to === "string" && !!to
|
|
53
|
-
&& to !== from;
|
|
28
|
+
const r = v;
|
|
29
|
+
return nonEmpty(r.id) && nonEmpty(r.from) && nonEmpty(r.to) && r.to !== r.from;
|
|
54
30
|
}
|
|
55
|
-
function
|
|
56
|
-
if (
|
|
31
|
+
function isDropped(v) {
|
|
32
|
+
if (typeof v !== "object" || v === null)
|
|
57
33
|
return false;
|
|
58
|
-
const
|
|
59
|
-
return
|
|
60
|
-
&& typeof from === "string" && !!from
|
|
61
|
-
&& typeof to === "string" && !!to;
|
|
34
|
+
const r = v;
|
|
35
|
+
return nonEmpty(r.id) && nonEmpty(r.from) && nonEmpty(r.to);
|
|
62
36
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
export function readPendingRepairs(root) {
|
|
66
|
-
const path = queuePath(root);
|
|
67
|
-
if (!existsSync(path))
|
|
37
|
+
function readList(file, guard) {
|
|
38
|
+
if (!existsSync(file))
|
|
68
39
|
return [];
|
|
69
40
|
try {
|
|
70
|
-
const parsed = JSON.parse(readFileSync(
|
|
71
|
-
return Array.isArray(parsed) ? parsed.filter(
|
|
41
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
42
|
+
return Array.isArray(parsed) ? parsed.filter(guard) : [];
|
|
72
43
|
}
|
|
73
44
|
catch {
|
|
74
45
|
return [];
|
|
75
46
|
}
|
|
76
47
|
}
|
|
48
|
+
/** The raw queue. Missing, corrupt or non-array files read as `[]`; never throws. */
|
|
49
|
+
export function readPendingRepairs(root) {
|
|
50
|
+
return readList(queuePath(root), isRewrite);
|
|
51
|
+
}
|
|
52
|
+
/** Atomically replace the queue. Writing `[]` clears it. */
|
|
77
53
|
export function writePendingRepairs(root, rewrites) {
|
|
78
54
|
writeFileAtomic(queuePath(root), JSON.stringify(rewrites, null, 2) + "\n");
|
|
79
55
|
}
|
|
80
|
-
/**
|
|
56
|
+
/** The tombstones of rewrites rejected with `--drop`. Same tolerance as the queue. */
|
|
81
57
|
export function readDroppedRepairs(root) {
|
|
82
|
-
|
|
83
|
-
if (!existsSync(path))
|
|
84
|
-
return [];
|
|
85
|
-
try {
|
|
86
|
-
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
87
|
-
return Array.isArray(parsed) ? parsed.filter(isDroppedRewrite) : [];
|
|
88
|
-
}
|
|
89
|
-
catch {
|
|
90
|
-
return [];
|
|
91
|
-
}
|
|
58
|
+
return readList(droppedPath(root), isDropped);
|
|
92
59
|
}
|
|
60
|
+
/** Atomically replace the tombstone file. Writing `[]` clears it. */
|
|
93
61
|
export function writeDroppedRepairs(root, dropped) {
|
|
94
62
|
writeFileAtomic(droppedPath(root), JSON.stringify(dropped, null, 2) + "\n");
|
|
95
63
|
}
|
|
96
|
-
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
* `repair-provenance`'s own action is the one exception: it needs the RAW
|
|
103
|
-
* read (via readPendingRepairs) because it diffs raw-vs-swept to report what
|
|
104
|
-
* it cleaned up, and it persists the sweep back to disk. Every other
|
|
105
|
-
* reader — `hunch escalations`, SessionStart orientation, the `hunch_now`
|
|
106
|
-
* and `hunch_escalations` MCP tools — must call this instead, or a
|
|
107
|
-
* tombstoned entry that hasn't yet been swept by a `repair-provenance` run
|
|
108
|
-
* re-surfaces as if the human never answered it. */
|
|
64
|
+
/**
|
|
65
|
+
* The raw queue minus anything tombstoned. Read-only: it never changes either
|
|
66
|
+
* file. Every read-only consumer uses this -- `hunch escalations`, SessionStart
|
|
67
|
+
* orientation, and the `hunch_now` and `hunch_escalations` MCP tools. Only
|
|
68
|
+
* `repair-provenance` itself reads the raw queue.
|
|
69
|
+
*/
|
|
109
70
|
export function readActivePendingRepairs(root) {
|
|
110
71
|
return withoutDropped(readPendingRepairs(root), readDroppedRepairs(root));
|
|
111
72
|
}
|
|
112
|
-
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
|
|
118
|
-
* `--apply --only <id>` that's guaranteed to no-op forever. `null` from
|
|
119
|
-
* commitsExist (the check itself failed to run) is treated as fail-open —
|
|
120
|
-
* same discipline as the apply path — so nothing is misreported as
|
|
121
|
-
* withheld just because the check couldn't run.
|
|
122
|
-
*
|
|
123
|
-
* Returns the OBJECTS, not their ids, and every caller must pass the exact
|
|
124
|
-
* same `queued` array into both this function and whatever it hands the
|
|
125
|
-
* result to (commitRepairEscalations) — an id-keyed set would collapse a
|
|
126
|
-
* corrupted queue's two same-id entries (one resolvable, one not) into one
|
|
127
|
-
* unit, exactly the identity confusion withheldForUnresolvableTo's own
|
|
128
|
-
* docstring warns about. */
|
|
73
|
+
/**
|
|
74
|
+
* The queued entries whose proposed `to` does not resolve in this repository.
|
|
75
|
+
* Fails open (nothing withheld) when git cannot answer. The set holds the
|
|
76
|
+
* caller's own objects, so callers must pass the same `queued` array here and to
|
|
77
|
+
* whatever consumes the result.
|
|
78
|
+
*/
|
|
129
79
|
export function withheldRewrites(root, queued) {
|
|
130
80
|
const existing = commitsExist(queued.map((r) => r.to), root);
|
|
131
81
|
return new Set(withheldForUnresolvableTo(queued, existing).withheld);
|
|
@@ -190,6 +190,39 @@ function scanIdentifiers(text) {
|
|
|
190
190
|
export function identifierTokens(text) {
|
|
191
191
|
return new Set(scanIdentifiers(text).keys());
|
|
192
192
|
}
|
|
193
|
+
/** Word -> the `id:` terms of the camel compounds the prompt splits it out of (PowerShell ->
|
|
194
|
+
* power, shell; GitHubActions -> git, hub, actions), sorted so prompt word order never matters.
|
|
195
|
+
* A word the prompt also uses on its own belongs to no compound. */
|
|
196
|
+
function camelCompoundGroups(text) {
|
|
197
|
+
const groups = new Map();
|
|
198
|
+
const compounds = text.match(CAMEL_IDENTIFIER) ?? [];
|
|
199
|
+
if (!compounds.length)
|
|
200
|
+
return groups;
|
|
201
|
+
const standalone = lexicalTokens(text.replace(CAMEL_IDENTIFIER, " "));
|
|
202
|
+
for (const m of compounds) {
|
|
203
|
+
const key = ID_PREFIX + m.toLowerCase();
|
|
204
|
+
for (const word of lexicalTokens(m)) {
|
|
205
|
+
if (standalone.has(word))
|
|
206
|
+
continue;
|
|
207
|
+
const keys = groups.get(word) ?? [];
|
|
208
|
+
if (!keys.includes(key))
|
|
209
|
+
keys.push(key);
|
|
210
|
+
groups.set(word, keys.sort());
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return groups;
|
|
214
|
+
}
|
|
215
|
+
/** A pasted stack frame: `at fn (file:line:col)`, `at file:line:col`, `at X (<anonymous>)`, `at X (node:...)`.
|
|
216
|
+
* Prose that merely starts with "at" ("at startup the lock goes stale") has no such tail. */
|
|
217
|
+
const FRAME_LINE = /^\s*at\s+\S+.*(?::\d+:\d+\)?|\(<anonymous>\)|\(node:[^)]*\))\s*$/;
|
|
218
|
+
/** Text whose words count: every line, unless the prompt holds an actual trace (2+ frame
|
|
219
|
+
* lines), whose frames name library internals (process, tick, sync), not the task. A Node
|
|
220
|
+
* warning's `(node:<pid>)` prefix is noise either way; the words after it stay. */
|
|
221
|
+
function promptWordText(prompt) {
|
|
222
|
+
const lines = prompt.split(/\r?\n/);
|
|
223
|
+
const trace = lines.filter((line) => FRAME_LINE.test(line)).length >= 2;
|
|
224
|
+
return (trace ? lines.filter((line) => !FRAME_LINE.test(line)) : lines).join("\n").replace(/\(node:\d+\)/g, " ");
|
|
225
|
+
}
|
|
193
226
|
const termTokens = (text) => new Set([...lexicalTokens(text), ...identifierTokens(text)]);
|
|
194
227
|
/** Score live records against one prompt. Text fields per kind (title + rationale):
|
|
195
228
|
* decision title/context/decision; bug title/symptom/root_cause; constraint
|
|
@@ -199,9 +232,9 @@ const termTokens = (text) => new Set([...lexicalTokens(text), ...identifierToken
|
|
|
199
232
|
export function selectForTask(records, promptText, opts = {}) {
|
|
200
233
|
const k = Math.max(0, opts.k ?? DEFAULT_K);
|
|
201
234
|
// Stack-frame lines name library APIs (writeFileSync, processTicksAndRejections), not the task:
|
|
202
|
-
//
|
|
203
|
-
const promptIdentifiers = (promptText ?? "")
|
|
204
|
-
const taskTerms = new Set([...lexicalTokens(
|
|
235
|
+
// when the prompt holds a real trace, neither their words nor their identifiers count.
|
|
236
|
+
const promptIdentifiers = promptWordText(promptText ?? "");
|
|
237
|
+
const taskTerms = new Set([...lexicalTokens(promptIdentifiers), ...identifierTokens(promptIdentifiers)]);
|
|
205
238
|
// Without an existence check (pure/offline) only a slashed token is trusted as a
|
|
206
239
|
// path: "e.g.", "Node.js" or "v1.2" would otherwise match a broad glob.
|
|
207
240
|
const paths = promptPaths(promptText ?? "", opts.root).filter((p) => opts.pathExists ? opts.pathExists(p) : p.includes("/"));
|
|
@@ -230,6 +263,15 @@ export function selectForTask(records, promptText, opts = {}) {
|
|
|
230
263
|
for (const c of candidates)
|
|
231
264
|
for (const term of c.tokens)
|
|
232
265
|
df.set(term, (df.get(term) ?? 0) + 1);
|
|
266
|
+
// A camel compound in many records is a brand (GitHub, PowerShell, GitHubActions): in a record
|
|
267
|
+
// holding it, its parts count as one word. A near-unique one (df <= MIN_DF_CAP, e.g. QuotaReaper)
|
|
268
|
+
// names this project, and in a record without the compound a part is an ordinary word.
|
|
269
|
+
const wordGroups = new Map();
|
|
270
|
+
for (const [word, keys] of camelCompoundGroups(promptIdentifiers)) {
|
|
271
|
+
const common = keys.filter((key) => (df.get(key) ?? 0) > MIN_DF_CAP);
|
|
272
|
+
if (common.length)
|
|
273
|
+
wordGroups.set(word, common);
|
|
274
|
+
}
|
|
233
275
|
const strongIds = new Set([...scanIdentifiers(promptIdentifiers)].filter(([, strong]) => strong).map(([term]) => term));
|
|
234
276
|
const rareTerms = new Map();
|
|
235
277
|
for (const term of taskTerms) {
|
|
@@ -247,7 +289,7 @@ export function selectForTask(records, promptText, opts = {}) {
|
|
|
247
289
|
}
|
|
248
290
|
const scored = [];
|
|
249
291
|
for (const { tokens, anchors, ...rest } of candidates) {
|
|
250
|
-
|
|
292
|
+
const words = new Set();
|
|
251
293
|
let identifiers = 0;
|
|
252
294
|
let termScore = 0;
|
|
253
295
|
for (const [term, { weight, strong }] of rareTerms) {
|
|
@@ -256,12 +298,12 @@ export function selectForTask(records, promptText, opts = {}) {
|
|
|
256
298
|
if (strong)
|
|
257
299
|
identifiers++;
|
|
258
300
|
else
|
|
259
|
-
words
|
|
301
|
+
words.add(wordGroups.get(term)?.find((key) => tokens.has(key)) ?? term);
|
|
260
302
|
termScore += weight;
|
|
261
303
|
}
|
|
262
304
|
const pathHits = paths.filter((p) => anchors.some((anchor) => pathMatchesGlob(p, anchor))).length;
|
|
263
305
|
// One rare identifier alone qualifies (it counts double); a lone rare word does not.
|
|
264
|
-
if (pathHits > 0 || words + IDENTIFIER_WEIGHT * identifiers >= MIN_DISTINCT_TERMS)
|
|
306
|
+
if (pathHits > 0 || words.size + IDENTIFIER_WEIGHT * identifiers >= MIN_DISTINCT_TERMS)
|
|
265
307
|
scored.push({ ...rest, pathHits, termScore });
|
|
266
308
|
}
|
|
267
309
|
// A record anchored to more of the files the prompt names outranks one that
|