@davesheffer/hunch 1.8.2 → 1.9.2

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 (59) hide show
  1. package/README.md +96 -1
  2. package/dist/cli/index.js +1238 -396
  3. package/dist/constitution/adapters.js +31 -14
  4. package/dist/constitution/behaviorEvaluator.js +20 -7
  5. package/dist/constitution/behaviorProof.js +3 -2
  6. package/dist/constitution/canonical.js +7 -1
  7. package/dist/constitution/card.js +7 -2
  8. package/dist/constitution/compiler.js +71 -1
  9. package/dist/constitution/correctionPolicyMaterializer.js +496 -0
  10. package/dist/constitution/delta.js +3 -2
  11. package/dist/constitution/evaluator.js +29 -3
  12. package/dist/constitution/experiment.js +96 -5
  13. package/dist/constitution/experimentRunner.js +43 -14
  14. package/dist/constitution/g2BehaviorCandidates.js +49 -26
  15. package/dist/constitution/g2BehaviorDependencies.js +203 -14
  16. package/dist/constitution/g2Candidates.js +1 -1
  17. package/dist/constitution/lifecycle.js +17 -0
  18. package/dist/constitution/plan.js +26 -9
  19. package/dist/constitution/replacementFreeGit.js +67 -0
  20. package/dist/constitution/replay.js +6 -0
  21. package/dist/constitution/replayCache.js +1 -1
  22. package/dist/constitution/replayWorker.js +1 -1
  23. package/dist/constitution/repository.js +141 -5
  24. package/dist/constitution/safeCheckout.js +75 -0
  25. package/dist/constitution/schema.js +30 -5
  26. package/dist/constitution/service.js +74 -14
  27. package/dist/constitution/sourceMutation.js +65 -12
  28. package/dist/constitution/staticGraphBaseline.js +44 -0
  29. package/dist/constitution/structural.js +60 -4
  30. package/dist/core/autoreview.js +1 -1
  31. package/dist/core/canonicalOrder.js +6 -0
  32. package/dist/core/conformance.js +68 -27
  33. package/dist/core/docscan.js +2 -1
  34. package/dist/core/escalations.js +11 -0
  35. package/dist/core/io.js +44 -9
  36. package/dist/core/overlaySafety.js +178 -0
  37. package/dist/core/paths.js +13 -2
  38. package/dist/core/safeRepoFile.js +74 -0
  39. package/dist/extractors/comments.js +6 -8
  40. package/dist/extractors/git.js +1631 -82
  41. package/dist/extractors/indexer.js +86 -47
  42. package/dist/extractors/repoSource.js +390 -0
  43. package/dist/integrations/ciAction.js +10 -2
  44. package/dist/integrations/gitignore.js +44 -5
  45. package/dist/integrations/mergeDriver.js +23 -5
  46. package/dist/integrations/sync.js +61 -5
  47. package/dist/integrations/team.js +666 -23
  48. package/dist/mcp/server.js +261 -34
  49. package/dist/store/db.js +57 -7
  50. package/dist/store/hunchStore.js +92 -11
  51. package/dist/store/jsonStore.js +350 -63
  52. package/dist/store/schema.js +27 -11
  53. package/dist/synthesis/provider.js +13 -4
  54. package/dist/synthesis/synthesize.js +56 -19
  55. package/dist/wiki/graph.js +5 -4
  56. package/dist/wiki/wiki.js +16 -10
  57. package/package.json +15 -3
  58. package/tooling/competitive-watch.mjs +108 -0
  59. package/tooling/md1-benchmark.mjs +628 -0
@@ -272,6 +272,13 @@ const MODEL_RE = /^[A-Za-z0-9._:/-]+$/;
272
272
  export function safeModel(v, fallback) {
273
273
  return v && MODEL_RE.test(v) ? v : fallback;
274
274
  }
275
+ /** Build the non-interactive Codex invocation used from Hunch's neutral temp
276
+ * directory. Codex normally refuses to start outside a trusted Git repository;
277
+ * the explicit skip flag preserves that neutral-cwd isolation without loading a
278
+ * target repo's agent rules or MCP configuration. */
279
+ export function codexExecArgs(model) {
280
+ return ["exec", "--json", "--skip-git-repo-check", ...(model ? ["-m", model] : []), "-"];
281
+ }
275
282
  // A timeout comes from a HUNCH_*_TIMEOUT_MS env var and feeds AbortController's
276
283
  // delay directly (never a shell argv token, unlike safeModel's model id) — but a
277
284
  // non-numeric or nonsensical value (negative, zero, NaN, Infinity) would either
@@ -366,10 +373,12 @@ class CodexCliProvider extends PromptSynthProvider {
366
373
  }
367
374
  }
368
375
  async run(prompt) {
369
- // `codex exec --json -` reads the prompt from STDIN (the `-`), emits JSONL
370
- // events. Strip OPENAI_API_KEY so it uses ChatGPT (subscription) auth, not the
371
- // pay-per-token API consistent with the subscription-only rule.
372
- const args = ["exec", "--json", ...(this.model ? ["-m", this.model] : []), "-"];
376
+ // `codex exec --json --skip-git-repo-check -` reads the prompt from STDIN
377
+ // (the `-`) and emits JSONL events. The skip flag is required because the
378
+ // shared runner deliberately uses a neutral temp cwd. Strip OPENAI_API_KEY so
379
+ // it uses ChatGPT (subscription) auth, not the pay-per-token API consistent
380
+ // with the subscription-only rule.
381
+ const args = codexExecArgs(this.model);
373
382
  const out = await this.runCli("codex", args, ["OPENAI_API_KEY"], prompt);
374
383
  return extractCodexText(out);
375
384
  }
@@ -59,7 +59,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
59
59
  // Check the store this capture WILL write to. Looking only in the public store
60
60
  // made private/shared re-syncs re-draft the same commit and let `--force`
61
61
  // overwrite a human-confirmed overlay decision.
62
- const home = store.captureHome(!!opts.private);
62
+ const home = opts.home ?? store.captureHome(!!opts.private);
63
63
  const existing = home === "private" ? store.getPrivateRec("decisions", id) : store.json.get("decisions", id);
64
64
  // Never clobber a human-confirmed decision with a low-confidence auto-draft —
65
65
  // even under --force. Skip BEFORE synthesizing so we never pay for a draft we'd
@@ -203,9 +203,18 @@ export async function syncCommit(store, root, sha, opts = {}) {
203
203
  };
204
204
  // Route to the record's ONE home: the overlay when asked (--private) or in unified
205
205
  // ("shared") mode; else the public store. Same contract as every other capture path.
206
- store.putCapture("decisions", decision, opts.private);
206
+ if (home === "private")
207
+ store.putPrivate("decisions", decision);
208
+ else
209
+ store.json.put("decisions", decision);
207
210
  return { status: "written", decision, provider: provider.name };
208
211
  }
212
+ function putBugInHome(store, bug, home) {
213
+ return home === "private" ? store.putPrivate("bugs", bug) : store.json.put("bugs", bug);
214
+ }
215
+ function putConstraintInHome(store, constraint, home) {
216
+ return home === "private" ? store.putPrivate("constraints", constraint) : store.json.put("constraints", constraint);
217
+ }
209
218
  /** Capture a Bug from a test failure. Suspects are ranked churn×recency×fan-in. */
210
219
  export async function recordFailure(store, root, failure, opts = {}) {
211
220
  const symbols = store.json.loadAll("symbols");
@@ -257,17 +266,17 @@ export async function recordFailure(store, root, failure, opts = {}) {
257
266
  evidence: [`test:${failure.test}`, ...affectedFiles.slice(0, 6)],
258
267
  },
259
268
  };
260
- store.putCapture("bugs", bug, opts.private);
269
+ putBugInHome(store, bug, home);
261
270
  // Promotion (DESIGN §4): a recurrence or a SUBSTANTIATED high-severity bug raises
262
271
  // a regression Constraint to stop it coming back, and bumps fragility.
263
272
  let constraint;
264
273
  if (shouldPromoteConstraint(draft.severity, bug.root_cause, !!prior)) {
265
- constraint = promoteConstraint(store, bug, opts.private);
274
+ constraint = promoteConstraint(store, bug, home);
266
275
  bug.lineage.spawned_constraint = constraint.id;
267
- store.putWhereItLives("bugs", bug); // re-persist with the link, in the same home
276
+ putBugInHome(store, bug, home);
268
277
  }
269
- raiseFragility(store, affectedFiles);
270
- return { status: "written", bug, constraint, provider: provider.name };
278
+ raiseFragility(store, affectedFiles, home);
279
+ return { status: "written", bug, constraint, provider: provider.name, touchedHomes: [home] };
271
280
  }
272
281
  /** Orchestrate one `hunch test` run into graph writes: capture each failing test
273
282
  * as a Bug (recordFailure → suspects / recurrence / Constraint promotion), and
@@ -286,8 +295,10 @@ export async function captureTestRun(store, root, input) {
286
295
  fallback = true;
287
296
  }
288
297
  const results = [];
298
+ const touchedHomes = new Set();
289
299
  for (const f of failures) {
290
300
  const r = await recordFailure(store, root, f, { private: input.private });
301
+ r.touchedHomes.forEach((home) => touchedHomes.add(home));
291
302
  results.push({ bug: r.bug, constraint: r.constraint });
292
303
  }
293
304
  let sha = null;
@@ -296,15 +307,17 @@ export async function captureTestRun(store, root, input) {
296
307
  }
297
308
  catch { /* not a git repo / no HEAD — leave null */ }
298
309
  const fixed = [];
310
+ const home = store.captureHome(!!input.private);
299
311
  for (const name of report.passed) {
300
- const b = store.getRec("bugs", bugId(name)); // a unified-mode bug lives in the overlay
312
+ const b = home === "private" ? store.getPrivateRec("bugs", bugId(name)) : store.json.get("bugs", bugId(name));
301
313
  if (b && b.status === "open") {
302
314
  const resolved = { ...b, status: "fixed", lineage: { ...b.lineage, fixed_commit: sha } };
303
- store.putWhereItLives("bugs", resolved);
315
+ putBugInHome(store, resolved, home);
316
+ touchedHomes.add(home);
304
317
  fixed.push(resolved);
305
318
  }
306
319
  }
307
- return { results, fixed, fallback };
320
+ return { results, fixed, fallback, touchedHomes: [...touchedHomes] };
308
321
  }
309
322
  /** Whether a bug should auto-promote a regression Constraint (a do-not-break
310
323
  * invariant). A recurrence always does. Otherwise it must be high/critical AND
@@ -318,7 +331,7 @@ export function shouldPromoteConstraint(severity, rootCause, isRecurrence) {
318
331
  return severe && rootCause.trim().length > 0;
319
332
  }
320
333
  /** Turn a bug into an advisory regression constraint scoped to its files. */
321
- function promoteConstraint(store, bug, isPrivate = false) {
334
+ function promoteConstraint(store, bug, home) {
322
335
  const scope = bug.affected_files.length ? bug.affected_files : ["**"];
323
336
  const statement = `Regression guard: "${bug.title}" must not recur.`;
324
337
  const con = {
@@ -338,16 +351,40 @@ function promoteConstraint(store, bug, isPrivate = false) {
338
351
  valid_to: null,
339
352
  provenance: { source: "derived", confidence: Math.min(0.9, bug.provenance.confidence + 0.2), evidence: [`bug:${bug.id}`] },
340
353
  };
341
- return store.putCapture("constraints", con, isPrivate);
354
+ return putConstraintInHome(store, con, home);
342
355
  }
343
356
  /** Bump fragility on components owning the affected files. */
344
- function raiseFragility(store, files) {
345
- const comps = store.json.loadAll("components");
346
- for (const c of comps) {
347
- if (files.some((f) => c.paths.some((g) => pathMatchesGlob(f, g)))) {
348
- const next = Math.min(1, Math.round((c.fragility + 0.1) * 100) / 100);
349
- if (next !== c.fragility)
350
- store.json.put("components", { ...c, fragility: next, updated_at: new Date().toISOString() });
357
+ function raiseFragility(store, files, home) {
358
+ const target = new Map(store.recsInHome("components", home).map((component) => [component.id, component]));
359
+ // Components are normally a public derived graph. A private/team failure must
360
+ // not mutate that public record, but it must still teach the private spine.
361
+ // Seed an overlay shadow from the latest public component on first failure;
362
+ // subsequent failures preserve its private curation while refreshing derived
363
+ // identity/path fields from public code.
364
+ const components = home === "private"
365
+ ? [...store.recsInHome("components", "public"), ...[...target.values()].filter((component) => !store.json.get("components", component.id))]
366
+ : [...target.values()];
367
+ for (const base of components) {
368
+ const prior = target.get(base.id);
369
+ const paths = base.paths;
370
+ if (files.some((f) => paths.some((g) => pathMatchesGlob(f, g)))) {
371
+ const current = Math.max(base.fragility, prior?.fragility ?? 0);
372
+ const next = Math.min(1, Math.round((current + 0.1) * 100) / 100);
373
+ if (!prior || next !== prior.fragility) {
374
+ const updated = {
375
+ ...base,
376
+ ...(prior ?? {}),
377
+ kind: base.kind,
378
+ name: base.name,
379
+ paths,
380
+ fragility: next,
381
+ updated_at: new Date().toISOString(),
382
+ };
383
+ if (home === "private")
384
+ store.putPrivate("components", updated);
385
+ else
386
+ store.json.put("components", updated);
387
+ }
351
388
  }
352
389
  }
353
390
  }
@@ -18,6 +18,7 @@
18
18
  * No CDN, no fetch — works as a local file in the private overlay repo
19
19
  * (con_547fff76bd).
20
20
  */
21
+ import { compareCodeUnits } from "../core/canonicalOrder.js";
21
22
  /** Pure assembly from already-computed wiki inputs — sorted for a stable hash. */
22
23
  export function assembleGraphData(kind, entries, decisionDates, repoDocs = [], adoptedPageByRel = new Map(), pendingReview = 0) {
23
24
  const ids = new Set(entries.map((e) => e.pack.component.id));
@@ -35,7 +36,7 @@ export function assembleGraphData(kind, entries, decisionDates, repoDocs = [], a
35
36
  .map((d) => (decisionDates.get(d.id) ?? "").slice(0, 10))
36
37
  .filter(Boolean)
37
38
  .sort(),
38
- })).sort((a, b) => a.id.localeCompare(b.id));
39
+ })).sort((a, b) => compareCodeUnits(a.id, b.id));
39
40
  const links = [];
40
41
  for (const e of entries) {
41
42
  for (const dep of e.pack.dependsOn) {
@@ -43,7 +44,7 @@ export function assembleGraphData(kind, entries, decisionDates, repoDocs = [], a
43
44
  links.push({ source: e.pack.component.id, target: dep.id });
44
45
  }
45
46
  }
46
- links.sort((a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target));
47
+ links.sort((a, b) => compareCodeUnits(a.source, b.source) || compareCodeUnits(a.target, b.target));
47
48
  const componentsByDoc = new Map();
48
49
  for (const e of entries) {
49
50
  for (const d of e.pack.docs) {
@@ -59,9 +60,9 @@ export function assembleGraphData(kind, entries, decisionDates, repoDocs = [], a
59
60
  title: d.title.slice(0, 80),
60
61
  status: d.status,
61
62
  adopted: adoptedPageByRel.get(d.rel) ?? null,
62
- components: [...new Set(componentsByDoc.get(d.rel) ?? [])].sort(),
63
+ components: [...new Set(componentsByDoc.get(d.rel) ?? [])].sort(compareCodeUnits),
63
64
  }))
64
- .sort((a, b) => a.rel.localeCompare(b.rel));
65
+ .sort((a, b) => compareCodeUnits(a.rel, b.rel));
65
66
  return { kind, nodes, links, docs, pendingReview };
66
67
  }
67
68
  export function renderGraphPage(data) {
package/dist/wiki/wiki.js CHANGED
@@ -30,6 +30,7 @@ import { createHash } from "node:crypto";
30
30
  import { existsSync, readFileSync, mkdirSync, rmSync } from "node:fs";
31
31
  import { join, dirname } from "node:path";
32
32
  import { writeFileAtomic } from "../core/io.js";
33
+ import { compareCodeUnits } from "../core/canonicalOrder.js";
33
34
  import { hunchPaths, toPosixTarget } from "../core/paths.js";
34
35
  import { isLive } from "../core/topics.js";
35
36
  import { scanRepoDocs } from "../core/docscan.js";
@@ -85,12 +86,12 @@ export function assemblePack(store, component, source = "public", repoDocs = [],
85
86
  const prefixes = component.paths.map(globPrefix);
86
87
  const symbols = read("symbols")
87
88
  .filter((s) => owns(prefixes, s.file))
88
- .sort((a, b) => b.metrics.fan_in - a.metrics.fan_in || b.metrics.loc - a.metrics.loc || a.name.localeCompare(b.name));
89
- const files = [...new Set(symbols.map((s) => toPosixTarget(s.file)))].sort();
89
+ .sort((a, b) => b.metrics.fan_in - a.metrics.fan_in || b.metrics.loc - a.metrics.loc || compareCodeUnits(a.name, b.name));
90
+ const files = [...new Set(symbols.map((s) => toPosixTarget(s.file)))].sort(compareCodeUnits);
90
91
  const decisions = read("decisions")
91
92
  .filter(isLive)
92
93
  .filter((d) => d.related_components.includes(component.id) || (d.related_files ?? []).some((f) => owns(prefixes, f)))
93
- .sort((a, b) => (b.valid_from ?? b.date).localeCompare(a.valid_from ?? a.date) || a.id.localeCompare(b.id))
94
+ .sort((a, b) => compareCodeUnits(b.valid_from ?? b.date, a.valid_from ?? a.date) || compareCodeUnits(a.id, b.id))
94
95
  .slice(0, 8)
95
96
  .map((d) => ({
96
97
  id: d.id, topic: d.topic, title: d.title, decision: clip(d.decision, 500), context: clip(d.context, 300),
@@ -102,13 +103,13 @@ export function assemblePack(store, component, source = "public", repoDocs = [],
102
103
  const constraints = read("constraints")
103
104
  .filter((c) => c.status !== "retired")
104
105
  .filter((c) => c.scope.some((g) => { const p = globPrefix(g); return p !== "" && prefixes.some((q) => q !== "" && (p.startsWith(q) || q.startsWith(p))); }))
105
- .sort((a, b) => (SEV[b.severity] ?? 0) - (SEV[a.severity] ?? 0) || a.id.localeCompare(b.id))
106
+ .sort((a, b) => (SEV[b.severity] ?? 0) - (SEV[a.severity] ?? 0) || compareCodeUnits(a.id, b.id))
106
107
  .slice(0, 8)
107
108
  .map((c) => ({ id: c.id, severity: c.severity, statement: clip(c.statement, 300), rationale: clip(c.rationale, 200) }));
108
109
  const symbolNames = new Set(symbols.map((s) => s.name));
109
110
  const bugs = read("bugs")
110
111
  .filter((b) => b.affected_files.some((f) => owns(prefixes, f)) || b.affected_symbols.some((s) => symbolNames.has(s)))
111
- .sort((a, b) => (SEV[b.severity] ?? 0) - (SEV[a.severity] ?? 0) || a.id.localeCompare(b.id))
112
+ .sort((a, b) => (SEV[b.severity] ?? 0) - (SEV[a.severity] ?? 0) || compareCodeUnits(a.id, b.id))
112
113
  .slice(0, 6)
113
114
  .map((b) => ({ id: b.id, title: b.title, root_cause: clip(b.root_cause, 250), severity: b.severity, status: b.status }));
114
115
  const componentsById = new Map(read("components").map((c) => [c.id, c]));
@@ -122,7 +123,7 @@ export function assemblePack(store, component, source = "public", repoDocs = [],
122
123
  if (e.to === component.id && componentsById.has(e.from) && e.from !== component.id)
123
124
  usedBy.set(e.from, componentsById.get(e.from).name);
124
125
  }
125
- const rel = (m) => [...m].map(([id, name]) => ({ id, name, slug: slugById.get(id) ?? null })).sort((a, b) => a.id.localeCompare(b.id));
126
+ const rel = (m) => [...m].map(([id, name]) => ({ id, name, slug: slugById.get(id) ?? null })).sort((a, b) => compareCodeUnits(a.id, b.id));
126
127
  const docs = repoDocs
127
128
  .filter((doc) => doc.srcRefs.some((f) => owns(prefixes, f)))
128
129
  .map((doc) => ({ path: doc.rel, title: doc.title, status: doc.status, adopted: adoptedPageByRel.get(doc.rel) ?? null }));
@@ -148,7 +149,7 @@ function canonical(v) {
148
149
  if (Array.isArray(v))
149
150
  return v.map(canonical);
150
151
  if (v && typeof v === "object") {
151
- return Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)).map(([k, x]) => [k, canonical(x)]));
152
+ return Object.fromEntries(Object.entries(v).sort(([a], [b]) => compareCodeUnits(a, b)).map(([k, x]) => [k, canonical(x)]));
152
153
  }
153
154
  return v;
154
155
  }
@@ -355,7 +356,7 @@ const ADOPTED_PREFIX = "doc:";
355
356
  * from the roadmap with zero file maintenance). Home-scoped like every read. */
356
357
  export function nowData(decisions, recentLimit = 10) {
357
358
  const clip1 = (s) => (s.length > 220 ? s.slice(0, 219).trimEnd() + "…" : s).replace(/\s*\n\s*/g, " ");
358
- const byDateDesc = (a, b) => (b.valid_from ?? b.date).localeCompare(a.valid_from ?? a.date) || a.id.localeCompare(b.id);
359
+ const byDateDesc = (a, b) => compareCodeUnits(b.valid_from ?? b.date, a.valid_from ?? a.date) || compareCodeUnits(a.id, b.id);
359
360
  const recent = [...decisions].sort(byDateDesc).slice(0, recentLimit)
360
361
  .map((d) => ({ id: d.id, topic: d.topic, title: d.title, status: d.status, date: (d.valid_from ?? d.date).slice(0, 10), note: clip1(d.decision) }));
361
362
  // Roadmap = INTENT the human vouched for. Auto-synthesized drafts are also
@@ -422,13 +423,18 @@ function pageState(home, page, component, hash, prior) {
422
423
  }
423
424
  return { state: "fresh", reason: "" };
424
425
  }
426
+ /** Stable component order is part of page identity because slug collisions are
427
+ * resolved first-come. Keep it independent of the host's collation locale. */
428
+ export function compareWikiComponents(left, right) {
429
+ return compareCodeUnits(left.name, right.name) || compareCodeUnits(left.id, right.id);
430
+ }
425
431
  export function wikiStatus(store, home, srcRoot) {
426
432
  const manifest = readWikiManifestAt(home.manifestPath);
427
433
  const decisions = home.source === "all" ? store.recs("decisions") : store.json.loadAll("decisions");
428
434
  const docs = scanRepoDocs(decisions, srcRoot);
429
435
  const components = (home.source === "all" ? store.recs("components") : store.json.loadAll("components"))
430
436
  .filter((c) => c.status === "active")
431
- .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
437
+ .sort(compareWikiComponents);
432
438
  // Adoption slugs are assigned FIRST (single authority): every stale doc gets a
433
439
  // wiki-managed, graph-healed copy, and the packs/renderers receive its path.
434
440
  const adoptedTaken = new Set();
@@ -473,7 +479,7 @@ export function wikiStatus(store, home, srcRoot) {
473
479
  // hand-editing it grades stale instead of staying invisible forever.
474
480
  const repoWide = (home.source === "all" ? store.recs("constraints") : store.json.loadAll("constraints"))
475
481
  .filter((c) => c.status !== "retired" && c.scope.every((g) => globPrefix(g) === ""))
476
- .sort((a, b) => (SEV[b.severity] ?? 0) - (SEV[a.severity] ?? 0) || a.id.localeCompare(b.id))
482
+ .sort((a, b) => (SEV[b.severity] ?? 0) - (SEV[a.severity] ?? 0) || compareCodeUnits(a.id, b.id))
477
483
  .slice(0, 10);
478
484
  const indexPage = `${home.dir}/README.md`;
479
485
  const indexHash = sha16(JSON.stringify(canonical({
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.8.2",
3
+ "version": "1.9.2",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
- "description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture — the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Antigravity, Codex).",
6
+ "description": "Engineering memory and a deterministic Change Gate for AI-assisted codebases: decisions, rejected approaches, constraints, and bug lineage become portable context and opt-in enforcement for every MCP assistant.",
7
7
  "homepage": "https://hunch-pi.vercel.app",
8
8
  "repository": {
9
9
  "type": "git",
@@ -19,6 +19,8 @@
19
19
  "files": [
20
20
  "dist/**/*.js",
21
21
  "bench/constitution-exp03-v1.json",
22
+ "tooling/competitive-watch.mjs",
23
+ "tooling/md1-benchmark.mjs",
22
24
  "LICENSE",
23
25
  "NOTICE"
24
26
  ],
@@ -33,7 +35,12 @@
33
35
  "windsurf",
34
36
  "antigravity",
35
37
  "mcp",
38
+ "coding-agents",
39
+ "agent-memory",
36
40
  "engineering-memory",
41
+ "architectural-conformance",
42
+ "change-gate",
43
+ "code-governance",
37
44
  "knowledge-graph",
38
45
  "code-intelligence",
39
46
  "ai",
@@ -47,11 +54,16 @@
47
54
  "build": "npm run clean && tsc -p tsconfig.json",
48
55
  "dev": "tsx src/cli/index.ts",
49
56
  "hunch": "tsx src/cli/index.ts",
50
- "test": "tsx --test test/*.test.ts",
57
+ "test": "tsx --test --test-concurrency=2 test/*.test.ts",
58
+ "test:e2e:md1": "tsx --test test/md1-e2e.test.ts",
59
+ "test:e2e:team-matrix": "tsx --test test/team-matrix-e2e.test.ts",
60
+ "verify:matrix": "node tooling/matrix-release-verification.mjs",
51
61
  "typecheck": "tsc -p tsconfig.json --noEmit",
52
62
  "rehearse:constitution": "npm run build && node tooling/constitution-clean-rehearsal.mjs",
53
63
  "gate:release": "node tooling/release-gate.mjs",
54
64
  "site:proof": "npm run build && node tooling/generate-public-proof.mjs",
65
+ "bench:md1": "node tooling/md1-benchmark.mjs",
66
+ "research:competitors": "node tooling/competitive-watch.mjs",
55
67
  "prepublishOnly": "npm run build"
56
68
  },
57
69
  "dependencies": {
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+
3
+ const repos = [
4
+ "davesheffer/hunch",
5
+ "gitmem-dev/gitmem",
6
+ "ismaelkedir/knowit",
7
+ "weigibbor/mnemo",
8
+ "oldskultxo/aictx",
9
+ "riponcm/projectmem",
10
+ "Cranot/roam-code",
11
+ "blackwell-systems/knowing",
12
+ ];
13
+
14
+ const distinctivePhrases = [
15
+ "Causal Merge Verdict",
16
+ "corrections become enforced",
17
+ "content-matched constraints",
18
+ "deterministic Change Gate for AI-assisted codebases",
19
+ ];
20
+
21
+ const token = process.env.GITHUB_TOKEN?.trim();
22
+ const headers = {
23
+ Accept: "application/vnd.github+json",
24
+ "User-Agent": "hunch-competitive-watch",
25
+ "X-GitHub-Api-Version": "2022-11-28",
26
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
27
+ };
28
+
29
+ async function github(path) {
30
+ const response = await fetch(`https://api.github.com${path}`, { headers });
31
+ if (!response.ok) {
32
+ const detail = (await response.text()).slice(0, 300).replaceAll("\n", " ");
33
+ throw new Error(`GitHub ${response.status} for ${path}: ${detail}`);
34
+ }
35
+ return response.json();
36
+ }
37
+
38
+ async function repoSnapshot(repo) {
39
+ const data = await github(`/repos/${repo}`);
40
+ return {
41
+ repo,
42
+ created: data.created_at,
43
+ pushed: data.pushed_at,
44
+ stars: data.stargazers_count,
45
+ forks: data.forks_count,
46
+ issues: data.open_issues_count,
47
+ url: data.html_url,
48
+ };
49
+ }
50
+
51
+ async function phraseSnapshot(phrase) {
52
+ const query = encodeURIComponent(`\"${phrase}\"`);
53
+ const data = await github(`/search/code?q=${query}&per_page=100`);
54
+ const external = data.items
55
+ .filter((item) => !item.repository.full_name.startsWith("davesheffer/"))
56
+ .map((item) => ({
57
+ repo: item.repository.full_name,
58
+ path: item.path,
59
+ url: item.html_url,
60
+ }));
61
+ return { phrase, total: data.total_count, external };
62
+ }
63
+
64
+ function render(snapshot, phrases) {
65
+ const lines = [
66
+ `# Competitive watch — ${new Date().toISOString()}`,
67
+ "",
68
+ "## Public repository signals",
69
+ "",
70
+ "| Repository | Created | Last push | Stars | Forks | Open issues |",
71
+ "| --- | --- | --- | ---: | ---: | ---: |",
72
+ ];
73
+
74
+ for (const item of snapshot) {
75
+ lines.push(
76
+ `| [${item.repo}](${item.url}) | ${item.created.slice(0, 10)} | ${item.pushed.slice(0, 10)} | ${item.stars} | ${item.forks} | ${item.issues} |`,
77
+ );
78
+ }
79
+
80
+ lines.push("", "## Distinctive phrase search", "");
81
+ if (!token) {
82
+ lines.push("Skipped: set `GITHUB_TOKEN` to enable authenticated GitHub code search.");
83
+ } else {
84
+ for (const result of phrases) {
85
+ lines.push(`- **${result.phrase}** — ${result.external.length} external indexed match(es)`);
86
+ for (const match of result.external) {
87
+ lines.push(` - [${match.repo} · ${match.path}](${match.url})`);
88
+ }
89
+ }
90
+ }
91
+
92
+ lines.push(
93
+ "",
94
+ "> Signals are leads, not copying findings. Re-check chronology and substantial similarity before drawing a conclusion.",
95
+ );
96
+ return `${lines.join("\n")}\n`;
97
+ }
98
+
99
+ try {
100
+ const snapshot = await Promise.all(repos.map(repoSnapshot));
101
+ const phrases = token
102
+ ? await Promise.all(distinctivePhrases.map(phraseSnapshot))
103
+ : [];
104
+ process.stdout.write(render(snapshot, phrases));
105
+ } catch (error) {
106
+ process.stderr.write(`competitive-watch: ${error instanceof Error ? error.message : String(error)}\n`);
107
+ process.exitCode = 1;
108
+ }