@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.
- package/README.md +96 -1
- package/dist/cli/index.js +1238 -396
- package/dist/constitution/adapters.js +31 -14
- package/dist/constitution/behaviorEvaluator.js +20 -7
- package/dist/constitution/behaviorProof.js +3 -2
- package/dist/constitution/canonical.js +7 -1
- package/dist/constitution/card.js +7 -2
- package/dist/constitution/compiler.js +71 -1
- package/dist/constitution/correctionPolicyMaterializer.js +496 -0
- package/dist/constitution/delta.js +3 -2
- package/dist/constitution/evaluator.js +29 -3
- package/dist/constitution/experiment.js +96 -5
- package/dist/constitution/experimentRunner.js +43 -14
- package/dist/constitution/g2BehaviorCandidates.js +49 -26
- package/dist/constitution/g2BehaviorDependencies.js +203 -14
- package/dist/constitution/g2Candidates.js +1 -1
- package/dist/constitution/lifecycle.js +17 -0
- package/dist/constitution/plan.js +26 -9
- package/dist/constitution/replacementFreeGit.js +67 -0
- package/dist/constitution/replay.js +6 -0
- package/dist/constitution/replayCache.js +1 -1
- package/dist/constitution/replayWorker.js +1 -1
- package/dist/constitution/repository.js +141 -5
- package/dist/constitution/safeCheckout.js +75 -0
- package/dist/constitution/schema.js +30 -5
- package/dist/constitution/service.js +74 -14
- package/dist/constitution/sourceMutation.js +65 -12
- package/dist/constitution/staticGraphBaseline.js +44 -0
- package/dist/constitution/structural.js +60 -4
- package/dist/core/autoreview.js +1 -1
- package/dist/core/canonicalOrder.js +6 -0
- package/dist/core/conformance.js +68 -27
- package/dist/core/docscan.js +2 -1
- package/dist/core/escalations.js +11 -0
- package/dist/core/io.js +44 -9
- package/dist/core/overlaySafety.js +178 -0
- package/dist/core/paths.js +13 -2
- package/dist/core/safeRepoFile.js +74 -0
- package/dist/extractors/comments.js +6 -8
- package/dist/extractors/git.js +1631 -82
- package/dist/extractors/indexer.js +86 -47
- package/dist/extractors/repoSource.js +390 -0
- package/dist/integrations/ciAction.js +10 -2
- package/dist/integrations/gitignore.js +44 -5
- package/dist/integrations/mergeDriver.js +23 -5
- package/dist/integrations/sync.js +61 -5
- package/dist/integrations/team.js +666 -23
- package/dist/mcp/server.js +261 -34
- package/dist/store/db.js +57 -7
- package/dist/store/hunchStore.js +92 -11
- package/dist/store/jsonStore.js +350 -63
- package/dist/store/schema.js +27 -11
- package/dist/synthesis/provider.js +13 -4
- package/dist/synthesis/synthesize.js +56 -19
- package/dist/wiki/graph.js +5 -4
- package/dist/wiki/wiki.js +16 -10
- package/package.json +15 -3
- package/tooling/competitive-watch.mjs +108 -0
- 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
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
274
|
+
constraint = promoteConstraint(store, bug, home);
|
|
266
275
|
bug.lineage.spawned_constraint = constraint.id;
|
|
267
|
-
store
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
}
|
package/dist/wiki/graph.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.9.2",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
|
-
"description": "
|
|
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
|
+
}
|