@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.
Files changed (53) hide show
  1. package/dist/cli/index.js +139 -32
  2. package/dist/cli/invocation.d.ts +14 -0
  3. package/dist/cli/invocation.js +23 -0
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/experiment.d.ts +1 -1
  7. package/dist/core/agenthook.js +26 -6
  8. package/dist/core/boundedSpawn.d.ts +57 -0
  9. package/dist/core/boundedSpawn.js +321 -0
  10. package/dist/core/config.d.ts +6 -0
  11. package/dist/core/config.js +1 -0
  12. package/dist/core/delivery.d.ts +6 -1
  13. package/dist/core/delivery.js +71 -5
  14. package/dist/core/migrate.d.ts +10 -1
  15. package/dist/core/migrate.js +17 -6
  16. package/dist/core/projectDna.js +11 -1
  17. package/dist/core/relativeImports.js +6 -2
  18. package/dist/core/taskSelection.js +48 -6
  19. package/dist/core/testSelection.d.ts +122 -0
  20. package/dist/core/testSelection.js +588 -0
  21. package/dist/extractors/git.js +32 -31
  22. package/dist/extractors/indexer.js +42 -6
  23. package/dist/extractors/languages.d.ts +9 -0
  24. package/dist/extractors/languages.js +28 -0
  25. package/dist/extractors/parse.d.ts +20 -0
  26. package/dist/extractors/parse.js +81 -5
  27. package/dist/extractors/workspaces.js +3 -1
  28. package/dist/integrations/health.d.ts +6 -0
  29. package/dist/integrations/health.js +33 -12
  30. package/dist/integrations/hooks.d.ts +4 -2
  31. package/dist/integrations/hooks.js +12 -2
  32. package/dist/integrations/mergeDriver.js +5 -3
  33. package/dist/integrations/optout.d.ts +3 -0
  34. package/dist/integrations/optout.js +17 -0
  35. package/dist/integrations/providers.d.ts +6 -2
  36. package/dist/integrations/providers.js +19 -2
  37. package/dist/integrations/team.js +4 -2
  38. package/dist/integrations/uninstall.d.ts +10 -4
  39. package/dist/integrations/uninstall.js +22 -18
  40. package/dist/mcp/server.js +47 -8
  41. package/dist/serve/app.js +9 -1
  42. package/dist/serve/config.js +5 -0
  43. package/dist/serve/writelock.d.ts +3 -1
  44. package/dist/serve/writelock.js +17 -3
  45. package/dist/store/hunchStore.d.ts +2 -1
  46. package/dist/store/hunchStore.js +8 -5
  47. package/dist/store/jsonStore.d.ts +13 -3
  48. package/dist/store/jsonStore.js +37 -13
  49. package/dist/store/stateBinding.js +17 -4
  50. package/dist/synthesis/provider.js +26 -78
  51. package/dist/synthesis/synthesize.js +1 -1
  52. package/package.json +2 -2
  53. package/server.json +2 -2
@@ -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 is kept untouched (never
946
- * deleted) and counted as `skipped`, so migration can't lose data. The caller
947
- * bumps the manifest afterward. */
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 schema = SCHEMAS[kind];
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
- skipped++;
979
+ fail();
970
980
  continue;
971
981
  }
972
982
  if (!Array.isArray(arr)) {
973
- skipped++;
983
+ fail();
974
984
  continue;
975
985
  }
976
986
  const kept = [];
977
987
  for (const raw of arr) {
978
- const r = schema.safeParse(this.migrate(kind, raw, version));
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
- skipped++;
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
- skipped++;
1015
+ fail();
1002
1016
  continue;
1003
1017
  }
1004
- const r = schema.safeParse(this.migrate(kind, raw, version));
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
- skipped++; // leave the file as-is
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
- const matches = typeof request.expected_version === 'number'
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
- return new Promise((resolve, reject) => {
50
- // Windows: launch through cmd.exe so the `claude` .cmd/.ps1 shim resolves,
51
- // and pass the whole line as ONE shell string (args are trusted, space-free
52
- // flags) — avoids Node's DEP0190 warning for `args + shell:true`. The prompt
53
- // is never here; it goes via stdin below. POSIX: no shell, argv as-is.
54
- const child = IS_WIN
55
- ? spawn([cmd, ...args].join(" "), {
56
- shell: true,
57
- env: initiatorChildEnv(opts.env ?? process.env),
58
- cwd: opts.cwd,
59
- windowsHide: true,
60
- })
61
- : spawn(cmd, args, {
62
- env: initiatorChildEnv(opts.env ?? process.env),
63
- cwd: opts.cwd,
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) || 12;
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.45.2",
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.4.3"
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.45.2",
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.45.2",
16
+ "version": "1.46.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {