@davesheffer/hunch 1.45.2 → 1.46.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 +139 -32
- package/dist/cli/invocation.d.ts +14 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/experiment.d.ts +1 -1
- 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/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/projectDna.js +11 -1
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/extractors/git.js +32 -31
- package/dist/extractors/indexer.js +42 -6
- package/dist/extractors/languages.d.ts +9 -0
- package/dist/extractors/languages.js +28 -0
- package/dist/extractors/parse.d.ts +20 -0
- package/dist/extractors/parse.js +81 -5
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +4 -2
- package/dist/integrations/hooks.js +12 -2
- package/dist/integrations/mergeDriver.js +5 -3
- 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.js +47 -8
- 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 +8 -5
- 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.js +26 -78
- package/dist/synthesis/synthesize.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/store/jsonStore.js
CHANGED
|
@@ -8,7 +8,7 @@ import { randomBytes } from "node:crypto";
|
|
|
8
8
|
import { hostname } from "node:os";
|
|
9
9
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
10
|
import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
|
|
11
|
-
import { BASELINE_VERSION, migrateRaw, SCHEMA_VERSION } from "../core/migrate.js";
|
|
11
|
+
import { BASELINE_VERSION, hasPendingMigration, migrateRaw, SCHEMA_VERSION } from "../core/migrate.js";
|
|
12
12
|
import { writeFileAtomic } from "../core/io.js";
|
|
13
13
|
import { readStoreArtifact } from "../core/storeArtifact.js";
|
|
14
14
|
import { createdAfterLock, heldLockNonces, judgeSameHostOwner, processStartedAt, selfStartToken, startTokenOf } from "../core/procstart.js";
|
|
@@ -942,19 +942,29 @@ export class JsonStore {
|
|
|
942
942
|
return names.length;
|
|
943
943
|
}
|
|
944
944
|
/** Persist a schema migration: rewrite every LOADABLE record in its current shape.
|
|
945
|
-
* A record that still fails validation after migration
|
|
946
|
-
*
|
|
947
|
-
*
|
|
945
|
+
* A record that still fails validation after migration (or whose migration
|
|
946
|
+
* throws) is kept in its ORIGINAL shape (never deleted, never half-migrated) and
|
|
947
|
+
* counted as `skipped`, so migration can't lose data. The caller stamps the
|
|
948
|
+
* manifest at the returned `version`: SCHEMA_VERSION only when nothing was
|
|
949
|
+
* skipped, else the FROM version, so a manual fix plus a rerun re-applies the
|
|
950
|
+
* (idempotent) migrations to the skipped records instead of skipping them.
|
|
951
|
+
* A bad record of a kind NO pending migration touches can't be fixed by a
|
|
952
|
+
* rerun, so it is counted as `invalid` instead and doesn't hold the version. */
|
|
948
953
|
persistMigration() {
|
|
949
954
|
let migrated = 0;
|
|
950
955
|
let skipped = 0;
|
|
956
|
+
let invalid = 0;
|
|
951
957
|
this.cache.clear(); // every record is rewritten; drop all memoized loads
|
|
952
958
|
const version = this.schemaVersion(); // read once; we're migrating FROM this
|
|
953
959
|
for (const kind of ENTITY_KINDS) {
|
|
954
960
|
const directory = this.safeKindDirectory(kind, false);
|
|
955
961
|
if (!directory)
|
|
956
962
|
continue;
|
|
957
|
-
const
|
|
963
|
+
const pending = hasPendingMigration(kind, version);
|
|
964
|
+
const fail = () => { if (pending)
|
|
965
|
+
skipped++;
|
|
966
|
+
else
|
|
967
|
+
invalid++; };
|
|
958
968
|
const single = SINGLE_FILE[kind];
|
|
959
969
|
if (single) {
|
|
960
970
|
const f = join(directory.lexical, single);
|
|
@@ -966,23 +976,23 @@ export class JsonStore {
|
|
|
966
976
|
arr = JSON.parse(text);
|
|
967
977
|
}
|
|
968
978
|
catch {
|
|
969
|
-
|
|
979
|
+
fail();
|
|
970
980
|
continue;
|
|
971
981
|
}
|
|
972
982
|
if (!Array.isArray(arr)) {
|
|
973
|
-
|
|
983
|
+
fail();
|
|
974
984
|
continue;
|
|
975
985
|
}
|
|
976
986
|
const kept = [];
|
|
977
987
|
for (const raw of arr) {
|
|
978
|
-
const r =
|
|
988
|
+
const r = this.tryMigrate(kind, raw, version);
|
|
979
989
|
if (r.success) {
|
|
980
990
|
kept.push(r.data);
|
|
981
991
|
migrated++;
|
|
982
992
|
}
|
|
983
993
|
else {
|
|
984
994
|
kept.push(raw); // preserve unmigratable records rather than drop them
|
|
985
|
-
|
|
995
|
+
fail();
|
|
986
996
|
}
|
|
987
997
|
}
|
|
988
998
|
this.writeContainedFile(directory, f, encode(kept), this.maxBytes(kind));
|
|
@@ -995,24 +1005,38 @@ export class JsonStore {
|
|
|
995
1005
|
const text = this.readContainedFile(directory, p, this.maxBytes(kind));
|
|
996
1006
|
if (text === null)
|
|
997
1007
|
continue;
|
|
1008
|
+
// A 0-byte merge-driver tombstone holds no record (issue #37) — the
|
|
1009
|
+
// loader's rule; counting it would pin the manifest version forever.
|
|
1010
|
+
if (text.trim() === "")
|
|
1011
|
+
continue;
|
|
998
1012
|
raw = JSON.parse(text);
|
|
999
1013
|
}
|
|
1000
1014
|
catch {
|
|
1001
|
-
|
|
1015
|
+
fail();
|
|
1002
1016
|
continue;
|
|
1003
1017
|
}
|
|
1004
|
-
const r =
|
|
1018
|
+
const r = this.tryMigrate(kind, raw, version);
|
|
1005
1019
|
if (r.success) {
|
|
1006
1020
|
this.writeContainedFile(directory, p, encode(r.data), this.maxBytes(kind));
|
|
1007
1021
|
migrated++;
|
|
1008
1022
|
}
|
|
1009
1023
|
else {
|
|
1010
|
-
|
|
1024
|
+
fail(); // leave the file as-is
|
|
1011
1025
|
}
|
|
1012
1026
|
}
|
|
1013
1027
|
}
|
|
1014
1028
|
}
|
|
1015
|
-
return { migrated, skipped };
|
|
1029
|
+
return { migrated, skipped, invalid, version: skipped ? version : SCHEMA_VERSION };
|
|
1030
|
+
}
|
|
1031
|
+
/** Migrate + validate one raw record for persistMigration; a throwing migration
|
|
1032
|
+
* step counts as a failure, so the record stays in its original shape. */
|
|
1033
|
+
tryMigrate(kind, raw, version) {
|
|
1034
|
+
try {
|
|
1035
|
+
return SCHEMAS[kind].safeParse(this.migrate(kind, raw, version));
|
|
1036
|
+
}
|
|
1037
|
+
catch {
|
|
1038
|
+
return { success: false };
|
|
1039
|
+
}
|
|
1016
1040
|
}
|
|
1017
1041
|
}
|
|
1018
1042
|
//# sourceMappingURL=jsonStore.js.map
|
|
@@ -795,15 +795,21 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
795
795
|
// land its `superseded` event in THIS ledger, invisible to that partition's subscribers.
|
|
796
796
|
if (!inScope(supersededRecord))
|
|
797
797
|
throw new StateRefusal("conflict", `supersedes ${request.supersedes} is on record in ${scopePath(recordScope(supersededRecord, repo))}, not ${scopePath(request.scope)}: a write supersedes only a record in its own partition`, { incumbent_id: String(supersededRecord.id), reason: "supersede target in another partition" });
|
|
798
|
+
const matches = typeof request.expected_version === 'number'
|
|
799
|
+
? latestSeqFor(ledger, String(supersededRecord.id)) === request.expected_version
|
|
800
|
+
: request.expected_version === stateHash(supersededRecord);
|
|
798
801
|
if (stateHash(supersededVisibility ?? null) !== stateHash(nextVisibility ?? null)) {
|
|
799
802
|
if (supersededVisibility ? supersededVisibility.owner !== request.principal.id : request.principal.kind !== 'human')
|
|
800
803
|
deny();
|
|
801
|
-
|
|
802
|
-
? latestSeqFor(readLedger(hunchDir, request.scope), String(supersededRecord.id)) === request.expected_version
|
|
803
|
-
: request.expected_version === stateHash(supersededRecord);
|
|
804
|
-
if (!matches)
|
|
804
|
+
if (request.expected_version === null)
|
|
805
805
|
throw new StateRefusal('malformed', 'changing visibility during supersession requires the predecessor expected_version');
|
|
806
806
|
}
|
|
807
|
+
// A supersession's expected_version names the predecessor (#510): a stale one is a conflict.
|
|
808
|
+
if (request.expected_version !== null && !matches)
|
|
809
|
+
throw new StateRefusal("conflict", `expected_version does not match the superseded ${supersededRecord.id}`, { incumbent_id: String(supersededRecord.id), reason: "expected_version mismatch" });
|
|
810
|
+
}
|
|
811
|
+
else if (request.supersedes && request.expected_version !== null) {
|
|
812
|
+
throw new StateRefusal("conflict", `expected_version names ${request.supersedes}, which is not on record`, { incumbent_id: request.supersedes, reason: "expected_version without incumbent" });
|
|
807
813
|
}
|
|
808
814
|
if (!incumbent && !supersededRecord && nextVisibility?.owner !== undefined && nextVisibility.owner !== request.principal.id)
|
|
809
815
|
deny();
|
|
@@ -873,6 +879,13 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
873
879
|
if (!ok)
|
|
874
880
|
throw new StateRefusal("conflict", `expected_version does not match the incumbent ${id}`, { incumbent_id: id, reason: "expected_version mismatch" });
|
|
875
881
|
}
|
|
882
|
+
// Compare-and-swap with no incumbent (#510): a writer naming a version believes a record exists,
|
|
883
|
+
// so a fresh create under it is a stale read, never silently accepted. Numeric 0 is the ledger's
|
|
884
|
+
// "never seen" and asserts absence; a supersession's expected_version names its predecessor.
|
|
885
|
+
if (!existing && request.expected_version !== null && !request.supersedes
|
|
886
|
+
&& !(typeof request.expected_version === "number" && latestSeqFor(ledger, id) === request.expected_version)) {
|
|
887
|
+
throw new StateRefusal("conflict", `expected_version ${JSON.stringify(request.expected_version)} names a version, but ${id} has none on record: pass null to create it (0 asserts the id was never written)`, { incumbent_id: id, reason: "expected_version without incumbent" });
|
|
888
|
+
}
|
|
876
889
|
if (replayLink) {
|
|
877
890
|
const recordHash = stateHash(replayLink);
|
|
878
891
|
appendChanges(hunchDir, request.scope, missingEventFor(ledger, facet, replayLink, request.principal.id), { key: request.idempotency_key, entry: { record_id: replayLink.id, record_hash: recordHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
|
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
* Every provider returns the same shape so the rest of the system never knows
|
|
24
24
|
* (or cares) which one ran.
|
|
25
25
|
*/
|
|
26
|
-
import { execFile, spawn } from "node:child_process";
|
|
27
26
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
28
27
|
import { isIP } from "node:net";
|
|
29
28
|
import { tmpdir } from "node:os";
|
|
30
29
|
import { dirname, join } from "node:path";
|
|
30
|
+
import { boundedSpawn } from "../core/boundedSpawn.js";
|
|
31
31
|
import { writeFileAtomic } from "../core/io.js";
|
|
32
32
|
import { summarizeDiff } from "../extractors/diff.js";
|
|
33
33
|
import { languageFor } from "../extractors/languages.js";
|
|
@@ -45,84 +45,32 @@ const IS_WIN = process.platform === "win32";
|
|
|
45
45
|
* win32 routes through cmd.exe so the shim resolves. Safe here because
|
|
46
46
|
* every argv we pass is a trusted, space-free flag (the prompt is stdin).
|
|
47
47
|
*/
|
|
48
|
-
export function pexecIn(cmd, args, opts = {}) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
windowsHide: true,
|
|
65
|
-
});
|
|
66
|
-
const max = opts.maxBuffer ?? 16 * 1024 * 1024;
|
|
67
|
-
let out = "";
|
|
68
|
-
let err = "";
|
|
69
|
-
let outLen = 0;
|
|
70
|
-
let settled = false;
|
|
71
|
-
// Windows spawns through a cmd.exe wrapper (shell:true), and child.kill()
|
|
72
|
-
// terminates only that wrapper — the actual agent CLI survives as an orphan,
|
|
73
|
-
// still burning the user's subscription after every timeout, accumulating
|
|
74
|
-
// with each post-commit hook fire (issue #44). taskkill /T fells the tree.
|
|
75
|
-
// Best-effort by design: the wrapper kill below still runs either way.
|
|
76
|
-
const killTree = () => {
|
|
77
|
-
if (IS_WIN && child.pid) {
|
|
78
|
-
try {
|
|
79
|
-
execFile("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true }, () => { });
|
|
80
|
-
}
|
|
81
|
-
catch { /* taskkill unavailable — fall through to the wrapper kill */ }
|
|
82
|
-
}
|
|
83
|
-
child.kill();
|
|
84
|
-
};
|
|
85
|
-
const done = (fn) => {
|
|
86
|
-
if (settled)
|
|
87
|
-
return;
|
|
88
|
-
settled = true;
|
|
89
|
-
if (timer)
|
|
90
|
-
clearTimeout(timer);
|
|
91
|
-
fn();
|
|
92
|
-
};
|
|
93
|
-
const timer = opts.timeout
|
|
94
|
-
? setTimeout(() => {
|
|
95
|
-
killTree();
|
|
96
|
-
done(() => reject(new Error(`"${cmd}" timed out after ${opts.timeout}ms`)));
|
|
97
|
-
}, opts.timeout)
|
|
98
|
-
: null;
|
|
99
|
-
child.on("error", (e) => done(() => reject(e)));
|
|
100
|
-
child.stdout.on("data", (d) => {
|
|
101
|
-
outLen += d.length;
|
|
102
|
-
if (outLen > max) {
|
|
103
|
-
killTree();
|
|
104
|
-
done(() => reject(new Error(`"${cmd}" exceeded maxBuffer (${max} bytes)`)));
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
out += d.toString();
|
|
108
|
-
});
|
|
109
|
-
child.stderr.on("data", (d) => {
|
|
110
|
-
err += d.toString();
|
|
111
|
-
});
|
|
112
|
-
child.on("close", (code) => {
|
|
113
|
-
done(() => {
|
|
114
|
-
if (code === 0)
|
|
115
|
-
resolve({ stdout: out });
|
|
116
|
-
else
|
|
117
|
-
reject(new Error(`"${cmd}" exited ${code}: ${err.slice(0, 300)}`));
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
// Feed stdin (the prompt) then close it; commands with no input just get EOF.
|
|
121
|
-
if (opts.input != null)
|
|
122
|
-
child.stdin.write(opts.input);
|
|
123
|
-
child.stdin.on("error", () => { }); // ignore EPIPE if the child exits early
|
|
124
|
-
child.stdin.end();
|
|
48
|
+
export async function pexecIn(cmd, args, opts = {}) {
|
|
49
|
+
// Windows: launch through cmd.exe so the `claude` .cmd/.ps1 shim resolves,
|
|
50
|
+
// and pass the whole line as ONE shell string (args are trusted, space-free
|
|
51
|
+
// flags) — avoids Node's DEP0190 warning for `args + shell:true`. The prompt
|
|
52
|
+
// is never here; it goes via stdin. POSIX: no shell, argv as-is.
|
|
53
|
+
// boundedSpawn owns the timer and, on timeout or overflow, kills the WHOLE
|
|
54
|
+
// tree while the root is alive: on Windows child.kill() alone ends only the
|
|
55
|
+
// cmd.exe wrapper and orphans the agent CLI, still burning the user's
|
|
56
|
+
// subscription after every timeout (issues #44, #489).
|
|
57
|
+
const result = await boundedSpawn(IS_WIN ? [cmd, ...args].join(" ") : cmd, IS_WIN ? [] : args, {
|
|
58
|
+
shell: IS_WIN,
|
|
59
|
+
env: initiatorChildEnv(opts.env ?? process.env),
|
|
60
|
+
cwd: opts.cwd,
|
|
61
|
+
input: opts.input,
|
|
62
|
+
timeoutMs: opts.timeout,
|
|
63
|
+
maxBuffer: opts.maxBuffer ?? 16 * 1024 * 1024,
|
|
125
64
|
});
|
|
65
|
+
if (result.error)
|
|
66
|
+
throw result.error;
|
|
67
|
+
if (result.timedOut)
|
|
68
|
+
throw new Error(`"${cmd}" timed out after ${opts.timeout}ms`);
|
|
69
|
+
if (result.overflowed)
|
|
70
|
+
throw new Error(`"${cmd}" exceeded maxBuffer (${opts.maxBuffer ?? 16 * 1024 * 1024} bytes)`);
|
|
71
|
+
if (result.status !== 0)
|
|
72
|
+
throw new Error(`"${cmd}" exited ${result.status}: ${result.stderr.slice(0, 300)}`);
|
|
73
|
+
return { stdout: result.stdout };
|
|
126
74
|
}
|
|
127
75
|
/** Every selectable synthesis mode. `auto` is a preference value rather than a
|
|
128
76
|
* provider: it resolves the invocation's origin without choosing by availability.
|
|
@@ -12,7 +12,7 @@ import { isSubstantive } from "../extractors/languages.js";
|
|
|
12
12
|
const SKIP_SUBJECT = /^(merge|revert|bump|format|lint|wip)\b|^chore\(deps\):/i;
|
|
13
13
|
// Below this many changed code lines, a commit with no structural change and no
|
|
14
14
|
// explanatory body isn't worth a paid LLM call. Tunable via HUNCH_SIG_MIN_LINES.
|
|
15
|
-
const SIG_MIN_LINES = Number(process.env.HUNCH_SIG_MIN_LINES)
|
|
15
|
+
const SIG_MIN_LINES = ((raw) => Number.isFinite(raw) && raw > 0 ? raw : 12)(Number(process.env.HUNCH_SIG_MIN_LINES));
|
|
16
16
|
const SIG_MIN_BODY = 40;
|
|
17
17
|
/** Trivial-subject commits (merge/revert/bump/format/...) are noise UNLESS the body
|
|
18
18
|
* carries real content — a squash/PR description often lands there, not on the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.46.0",
|
|
4
4
|
"mcpName": "io.github.davesheffer/hunch",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"tree-sitter-php": "0.23.12",
|
|
104
104
|
"tree-sitter-python": "0.23.4",
|
|
105
105
|
"tree-sitter-typescript": "^0.23.2",
|
|
106
|
-
"zod": "^4.
|
|
106
|
+
"zod": "^4.6.6"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|
|
109
109
|
"@types/cross-spawn": "^6.0.6",
|
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.46.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.
|
|
16
|
+
"version": "1.46.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|