@davesheffer/hunch 1.7.0 → 1.8.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 (71) hide show
  1. package/README.md +242 -0
  2. package/bench/constitution-exp03-v1.json +70 -0
  3. package/dist/cli/index.js +1630 -107
  4. package/dist/constitution/adapters.js +487 -0
  5. package/dist/constitution/behaviorAttestationBinding.js +17 -0
  6. package/dist/constitution/behaviorEvaluator.js +220 -0
  7. package/dist/constitution/behaviorProof.js +205 -0
  8. package/dist/constitution/behaviorWorkspace.js +124 -0
  9. package/dist/constitution/bootstrap.js +133 -0
  10. package/dist/constitution/canonical.js +51 -0
  11. package/dist/constitution/card.js +133 -0
  12. package/dist/constitution/compiler.js +176 -0
  13. package/dist/constitution/composition.js +101 -0
  14. package/dist/constitution/corpus.js +58 -0
  15. package/dist/constitution/delta.js +154 -0
  16. package/dist/constitution/disposition.js +141 -0
  17. package/dist/constitution/evaluator.js +435 -0
  18. package/dist/constitution/experiment.js +1007 -0
  19. package/dist/constitution/experimentRunner.js +344 -0
  20. package/dist/constitution/g2.js +291 -0
  21. package/dist/constitution/g2BehaviorAttestation.js +209 -0
  22. package/dist/constitution/g2BehaviorCandidates.js +703 -0
  23. package/dist/constitution/g2BehaviorDependencies.js +379 -0
  24. package/dist/constitution/g2BehaviorMaterialization.js +171 -0
  25. package/dist/constitution/g2BehaviorPolicyMaterializer.js +241 -0
  26. package/dist/constitution/g2CandidateAttestation.js +179 -0
  27. package/dist/constitution/g2Candidates.js +195 -0
  28. package/dist/constitution/g2Drills.js +122 -0
  29. package/dist/constitution/g3.js +511 -0
  30. package/dist/constitution/g3Conformance.js +132 -0
  31. package/dist/constitution/lifecycle.js +224 -0
  32. package/dist/constitution/mutation.js +262 -0
  33. package/dist/constitution/nodeTestEvidence.js +47 -0
  34. package/dist/constitution/plan.js +172 -0
  35. package/dist/constitution/policyRuntime.js +8 -0
  36. package/dist/constitution/proof.js +166 -0
  37. package/dist/constitution/repairPolicies.js +78 -0
  38. package/dist/constitution/replay.js +361 -0
  39. package/dist/constitution/replayCache.js +89 -0
  40. package/dist/constitution/replayWorker.js +34 -0
  41. package/dist/constitution/repository.js +533 -0
  42. package/dist/constitution/schema.js +545 -0
  43. package/dist/constitution/scorecard.js +106 -0
  44. package/dist/constitution/service.js +1211 -0
  45. package/dist/constitution/shadow.js +235 -0
  46. package/dist/constitution/sourceMutation.js +316 -0
  47. package/dist/constitution/structural.js +601 -0
  48. package/dist/core/autoreview.js +27 -3
  49. package/dist/core/dupdetect.js +10 -3
  50. package/dist/core/escalations.js +65 -0
  51. package/dist/core/events.js +61 -0
  52. package/dist/core/externalImports.js +24 -0
  53. package/dist/core/hookpolicy.js +3 -0
  54. package/dist/core/memorylog.js +69 -0
  55. package/dist/core/relativeImports.js +33 -0
  56. package/dist/core/repair.js +71 -0
  57. package/dist/core/reviewqueue.js +11 -0
  58. package/dist/core/stats.js +115 -0
  59. package/dist/extractors/git.js +120 -0
  60. package/dist/extractors/indexer.js +39 -38
  61. package/dist/extractors/nativeTreeSitter.js +108 -0
  62. package/dist/extractors/parse.js +5 -15
  63. package/dist/integrations/claudemd.js +8 -1
  64. package/dist/integrations/gitignore.js +8 -0
  65. package/dist/integrations/providers.js +32 -10
  66. package/dist/integrations/sync.js +16 -1
  67. package/dist/mcp/server.js +317 -1
  68. package/dist/synthesis/synthesize.js +8 -1
  69. package/dist/wiki/graph.js +301 -0
  70. package/dist/wiki/wiki.js +31 -3
  71. package/package.json +5 -1
@@ -68,6 +68,15 @@ export function commitCoveredBy(codeFiles, subject, existing, nowMs) {
68
68
  }
69
69
  return best;
70
70
  }
71
+ /** Only a finalized, still-live human decision can justify deleting a draft as
72
+ * redundant. Proposed records may resemble one another, but choosing which one
73
+ * survives is review judgment—not deterministic hygiene. */
74
+ export function isAcceptedDuplicateAnchor(d) {
75
+ return d.status === "accepted"
76
+ && d.provenance.source.includes("human_confirmed")
77
+ && !d.superseded_by
78
+ && !d.valid_to;
79
+ }
71
80
  /** Is an existing DRAFT a near-duplicate of an accepted record? Review-time
72
81
  * flag; threshold callers use 0.35 (batch-reject) — conservative on purpose. */
73
82
  export function draftDuplicateOf(draft, existing) {
@@ -77,9 +86,7 @@ export function draftDuplicateOf(draft, existing) {
77
86
  for (const d of existing) {
78
87
  if (d.id === draft.id)
79
88
  continue;
80
- if (!d.provenance.source.includes("human_confirmed"))
81
- continue;
82
- if (d.status === "superseded" || d.status === "rejected" || d.superseded_by || d.valid_to)
89
+ if (!isAcceptedDuplicateAnchor(d))
83
90
  continue;
84
91
  const termSim = jaccard(draftTerms, dupTerms(`${d.title} ${d.decision}`));
85
92
  let fileBoost = 0;
@@ -0,0 +1,65 @@
1
+ import { topicCollisions } from "./topics.js";
2
+ /** The decisions a human must make NOW, to be asked INLINE. Empty in a healthy graph. */
3
+ export function pendingEscalations(decisions) {
4
+ const out = [];
5
+ for (const [topic, decs] of topicCollisions(decisions)) {
6
+ out.push({
7
+ kind: "topic-conflict",
8
+ topic,
9
+ decisionIds: decs.map((d) => d.id),
10
+ question: `Topic "${topic}" has ${decs.length} live decisions — which one is current?`,
11
+ detail: decs.map((d) => `${d.id} — "${d.title}"`).join(" · "),
12
+ resolution: `supersede the others: re-record the chosen one with supersedes:<other-id>, or split the topic.`,
13
+ });
14
+ }
15
+ return out;
16
+ }
17
+ /** The Constitution's genuine human moments (§59.5.3), framed as inline questions:
18
+ * a candidate awaiting review, and a proposed policy whose next step (prove, or
19
+ * accept/reject) is a human call. Machine conclusions never appear here as
20
+ * approvals — every entry is a QUESTION with its explicit resolution verb. */
21
+ export function policyEscalations(policies) {
22
+ const out = [];
23
+ const clip = (s) => (s.length > 90 ? s.slice(0, 89).trimEnd() + "…" : s);
24
+ for (const p of policies) {
25
+ // An auto-repaired policy asks FIRST (and only once): its bindings moved, so
26
+ // its proof is stale by construction — the human moment is "re-prove it".
27
+ if (p.last_action === "repaired" && (p.state === "proposed" || p.state === "active_advisory" || p.state === "active_blocking")) {
28
+ out.push({
29
+ kind: "policy-repaired",
30
+ topic: p.id,
31
+ decisionIds: [p.id],
32
+ question: `Rule "${clip(p.statement)}" (${p.id}) was auto-repaired after a rename — its proof is stale; re-prove it?`,
33
+ detail: `state ${p.state} · last action repaired · ${p.proof ? `proof ${p.proof} (stale)` : "no proof"}`,
34
+ resolution: `hunch policy prove ${p.id} — blocking stays fail-safe until the fresh proof lands`,
35
+ });
36
+ continue;
37
+ }
38
+ if (p.state === "compiled" || p.state === "validating") {
39
+ out.push({
40
+ kind: "policy-candidate",
41
+ topic: p.id,
42
+ decisionIds: [p.id],
43
+ question: `Candidate rule "${clip(p.statement)}" (${p.id}) awaits your review — keep it moving or reject it?`,
44
+ detail: `state ${p.state} · authority none · not yet proved`,
45
+ resolution: `hunch policy prove ${p.id} — then accept/reject; or hunch policy reject ${p.id} --reason "..."`,
46
+ });
47
+ }
48
+ else if (p.state === "proposed") {
49
+ out.push({
50
+ kind: "policy-proposal",
51
+ topic: p.id,
52
+ decisionIds: [p.id],
53
+ question: p.proof
54
+ ? `Proposed rule "${clip(p.statement)}" (${p.id}) carries its proof — activate it (advisory/blocking) or reject it?`
55
+ : `Proposed rule "${clip(p.statement)}" (${p.id}) has no current proof — prove it, then decide.`,
56
+ detail: `state proposed · ${p.proof ? `proof ${p.proof}` : "no proof"} · authority none`,
57
+ resolution: p.proof
58
+ ? `inspect: hunch policy card ${p.id} — then hunch policy accept ${p.id} --advisory|--blocking --actor human:<you>, or reject`
59
+ : `hunch policy prove ${p.id}`,
60
+ });
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ //# sourceMappingURL=escalations.js.map
@@ -0,0 +1,61 @@
1
+ /** The catch-log: an append-only, git-tracked JSONL trail of enforcement events
2
+ * (`.hunch/events.log`). Every time the deterministic gate actually BLOCKS an
3
+ * edit — a blocking invariant hit, or the Veto Guard reversing a rejected
4
+ * approach — one line is appended here. Without this trail "the graph caught 37
5
+ * violations" is an assertion; with it, it is a measurement (dec_6253f7e6d6).
6
+ *
7
+ * WHY append-only (not temp-file+rename like the JSON index, con_902759b3dc):
8
+ * the atomicity invariant exists so an interrupted write can never TRUNCATE the
9
+ * index. A single-line append can never truncate what is already on disk — it
10
+ * only ever extends it — so it honors that invariant's spirit without the O(n)
11
+ * read-modify-write a rewrite would cost per event. The log is a derived audit
12
+ * trail, never a source of truth: a lost or malformed line loses one catch's
13
+ * provenance, never a decision.
14
+ *
15
+ * HONESTY (dec_6253f7e6d6): a constraint/veto block carries NO subject/object/
16
+ * assert — those are conformance-only predicates checked by a different gate
17
+ * (`hunch conform`), not the edit hook. This schema records only what each gate
18
+ * actually knows; it never fabricates the conformance shape for a plain block. */
19
+ import { appendFileSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ export function eventsLogPath(paths) {
22
+ return join(paths.hunch, "events.log");
23
+ }
24
+ /** Append one event as a JSONL line. Best-effort and never throws: the primary
25
+ * call site is the edit hook, which MUST NEVER break an agent on failure
26
+ * (con_03a0b94b2e). A dropped catch-log line is an acceptable loss. */
27
+ export function appendEvent(paths, event) {
28
+ try {
29
+ appendFileSync(eventsLogPath(paths), `${JSON.stringify(event)}\n`);
30
+ }
31
+ catch {
32
+ /* best effort — a lost audit line must never surface to the agent */
33
+ }
34
+ }
35
+ /** Read + parse the catch-log. Malformed lines are skipped, not fatal (the log is
36
+ * derived; one bad line never poisons the aggregation). Missing log → []. */
37
+ export function readEvents(paths) {
38
+ let raw;
39
+ try {
40
+ raw = readFileSync(eventsLogPath(paths), "utf8");
41
+ }
42
+ catch {
43
+ return []; // no catches recorded yet
44
+ }
45
+ const out = [];
46
+ for (const line of raw.split("\n")) {
47
+ const s = line.trim();
48
+ if (!s)
49
+ continue;
50
+ try {
51
+ const e = JSON.parse(s);
52
+ if (e && typeof e.at === "string" && typeof e.kind === "string")
53
+ out.push(e);
54
+ }
55
+ catch {
56
+ /* skip a corrupt line */
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1,24 @@
1
+ import { shortHash } from "./ids.js";
2
+ /** Canonical package identity for a static module specifier. Relative, absolute,
3
+ * package-import-map (#), and URL-like specifiers are repository/runtime-local
4
+ * and deliberately outside the external-dependency evaluator. */
5
+ export function externalPackage(specifier) {
6
+ const value = specifier.trim();
7
+ if (!value || value.startsWith(".") || value.startsWith("/") || value.startsWith("#") || /^[a-z]+:\/\//i.test(value))
8
+ return null;
9
+ if (value.startsWith("node:"))
10
+ return /^node:[A-Za-z0-9_./-]+$/.test(value) ? value : null;
11
+ if (value.startsWith("@")) {
12
+ const [scope, name] = value.split("/");
13
+ return scope && name ? `${scope}/${name}` : null;
14
+ }
15
+ return value.split("/")[0] || null;
16
+ }
17
+ /** Virtual graph target for an external package. It is intentionally not a
18
+ * Component record: package facts stay a bounded evaluator layer and do not
19
+ * inflate the human-curated component graph. */
20
+ export function externalImportNodeId(specifier) {
21
+ const dependency = externalPackage(specifier);
22
+ return dependency ? `ext_${shortHash(dependency)}` : null;
23
+ }
24
+ //# sourceMappingURL=externalImports.js.map
@@ -16,6 +16,7 @@ export function blockingInScope(store, file, proposedAddedLines = []) {
16
16
  continue;
17
17
  return {
18
18
  reason: `Hunch: editing ${file} would touch a BLOCKING invariant — "${c.statement}" (${c.id}). Do not proceed unless this change is meant to modify that invariant; otherwise preserve it.`,
19
+ event: { kind: "constraint", constraint: c.id },
19
20
  };
20
21
  }
21
22
  for (const b of store.blastRadiusFiles(file)) {
@@ -29,6 +30,7 @@ export function blockingInScope(store, file, proposedAddedLines = []) {
29
30
  continue;
30
31
  return {
31
32
  reason: `Hunch: ${file} is in the blast radius of a BLOCKING invariant — "${c.statement}" (${c.id}; via ${b.file}, ${b.via} depth ${b.depth}). Verify the invariant still holds before editing.`,
33
+ event: { kind: "constraint", constraint: c.id },
32
34
  };
33
35
  }
34
36
  }
@@ -53,6 +55,7 @@ export function vetoInScope(store, file, proposedAddedLines) {
53
55
  return null;
54
56
  return {
55
57
  reason: `Hunch: editing ${file} would REVERSE decision ${hit.decision} — you rejected "${hit.alternative}" and chose "${hit.chosen}". Do not re-introduce the rejected approach; preserve the chosen design.`,
58
+ event: { kind: "veto", decision: hit.decision },
56
59
  };
57
60
  }
58
61
  //# sourceMappingURL=hookpolicy.js.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The memory-move timeline — the data behind `hunch log` and the VS Code "Hunch
3
+ * Source Control" view. Every commit that touched `.hunch/` is one MOVE (a capture,
4
+ * adoption, supersession, or prune), classified deterministically from git's own
5
+ * name-status. Pure parsing — no LLM, no judgment — so the panel shows exactly what
6
+ * git recorded and each move maps back to a real, revertable commit.
7
+ *
8
+ * The parser is split from the git shell-out (extractors/git.gitMemoryLog) so it is
9
+ * unit-testable with canned `git log` output.
10
+ */
11
+ /** The record-header separator we ask `git log --format` to emit, so header lines
12
+ * are unambiguous against the name-status lines that follow each commit. */
13
+ export const MEMLOG_HEADER = "@@@";
14
+ /** The `--format` string that pairs with {@link parseMemoryLog}. */
15
+ export const MEMLOG_FORMAT = `${MEMLOG_HEADER}%H\t%h\t%cI\t%s`;
16
+ const ID_RE = /\/((?:dec|bug|con|cmp|pol)_[0-9a-f]+)\.json$/;
17
+ /** Parse `git log <MEMLOG_FORMAT> --name-status -- .hunch/` output into classified
18
+ * moves, newest first (git's order). */
19
+ export function parseMemoryLog(raw) {
20
+ const moves = [];
21
+ let cur = null;
22
+ for (const line of raw.split("\n")) {
23
+ if (line.startsWith(MEMLOG_HEADER)) {
24
+ if (cur)
25
+ moves.push(classify(cur));
26
+ const f = line.slice(MEMLOG_HEADER.length).split("\t");
27
+ cur = {
28
+ sha: f[0] ?? "", shortSha: f[1] ?? "", date: f[2] ?? "", subject: f.slice(3).join("\t"),
29
+ kind: "edit", decisionIds: [], otherIds: [], added: 0, modified: 0, deleted: 0, files: [],
30
+ };
31
+ continue;
32
+ }
33
+ if (!cur || !line.trim())
34
+ continue;
35
+ // name-status: "A\tpath", "M\tpath", "D\tpath", or rename "R100\told\tnew".
36
+ const parts = line.split("\t");
37
+ const status = parts[0]?.[0];
38
+ const path = parts[parts.length - 1];
39
+ if (!path || !path.startsWith(".hunch/"))
40
+ continue;
41
+ cur.files.push(path);
42
+ if (status === "A")
43
+ cur.added++;
44
+ else if (status === "D")
45
+ cur.deleted++;
46
+ else
47
+ cur.modified++;
48
+ const id = ID_RE.exec(path)?.[1];
49
+ if (id)
50
+ (id.startsWith("dec_") ? cur.decisionIds : cur.otherIds).push(id);
51
+ }
52
+ if (cur)
53
+ moves.push(classify(cur));
54
+ return moves;
55
+ }
56
+ /** Deterministic move kind from the subject + the add/modify/delete shape. */
57
+ function classify(m) {
58
+ const s = m.subject.toLowerCase();
59
+ m.kind =
60
+ /\brepair\b/.test(s) ? "repair"
61
+ : /\badopt/.test(s) ? "adopt"
62
+ : /supersed/.test(s) ? "supersede"
63
+ : m.deleted > 0 && m.added === 0 && m.modified === 0 ? "prune"
64
+ : m.added > 0 && m.modified === 0 && m.deleted === 0 ? "capture"
65
+ : /\bcapture\b/.test(s) ? "capture"
66
+ : "edit";
67
+ return m;
68
+ }
69
+ //# sourceMappingURL=memorylog.js.map
@@ -0,0 +1,33 @@
1
+ import { dirname, join, posix } from "node:path";
2
+ function toPosix(path) {
3
+ return path.split(/[\\/]/).join(posix.sep);
4
+ }
5
+ /** Candidate source files for one static relative JS/TS import, in the same
6
+ * deterministic precedence order used by the indexer. Bare packages, URLs,
7
+ * absolute paths, and import-map aliases are deliberately unsupported. */
8
+ export function relativeImportCandidates(fromFile, specifier) {
9
+ if (!specifier.startsWith(".") || specifier.includes("\0"))
10
+ return [];
11
+ const base = toPosix(join(dirname(fromFile), specifier));
12
+ return [...new Set([
13
+ base.replace(/\.js$/, ".ts"),
14
+ base.replace(/\.js$/, ".tsx"),
15
+ base.replace(/\.jsx$/, ".tsx"),
16
+ base + ".ts",
17
+ base + ".tsx",
18
+ base,
19
+ base + ".js",
20
+ toPosix(join(base, "index.ts")),
21
+ toPosix(join(base, "index.tsx")),
22
+ toPosix(join(base, "index.js")),
23
+ ])];
24
+ }
25
+ /** Resolve against an exact file set. The first candidate preserves existing
26
+ * indexer compatibility; callers that need ambiguity metadata can inspect the
27
+ * returned matches instead of guessing a different target. */
28
+ export function resolveRelativeImport(fromFile, specifier, availableFiles) {
29
+ const available = new Set(availableFiles);
30
+ const matches = relativeImportCandidates(fromFile, specifier).filter((candidate) => available.has(candidate));
31
+ return { path: matches[0] ?? null, matches };
32
+ }
33
+ //# sourceMappingURL=relativeImports.js.map
@@ -0,0 +1,71 @@
1
+ /** A scope entry is exact (rewritable) only when it contains no glob syntax. */
2
+ function isExactPath(entry) {
3
+ return !/[*?[\]{}]/.test(entry);
4
+ }
5
+ /** Extract the rename pairs from a commit's change records (already 1:1 by git -M). */
6
+ export function renamesOf(changes) {
7
+ return changes
8
+ .filter((c) => c.status === "renamed" && c.before && c.after && c.before !== c.after)
9
+ .map((c) => ({ before: c.before, after: c.after }));
10
+ }
11
+ /** Plan every safe rewrite. Only exact matches against a git-confirmed rename move;
12
+ * live records only (a superseded/retired record's history stays as written). */
13
+ export function planRepair(renames, decisions, constraints) {
14
+ const map = new Map(renames.map((r) => [r.before, r.after]));
15
+ const rewrites = [];
16
+ if (!map.size)
17
+ return { rewrites, records: [] };
18
+ for (const d of decisions) {
19
+ if (d.status === "superseded" || d.status === "rejected")
20
+ continue;
21
+ for (const f of d.related_files ?? []) {
22
+ const to = map.get(f);
23
+ if (to)
24
+ rewrites.push({ kind: "decisions", id: d.id, field: "related_files", from: f, to });
25
+ }
26
+ for (const tw of d.rejected_tripwires ?? []) {
27
+ for (const s of tw.scope ?? []) {
28
+ const to = isExactPath(s) ? map.get(s) : undefined;
29
+ if (to)
30
+ rewrites.push({ kind: "decisions", id: d.id, field: "tripwire.scope", from: s, to });
31
+ }
32
+ }
33
+ }
34
+ for (const c of constraints) {
35
+ if (c.status && c.status !== "active")
36
+ continue;
37
+ for (const s of c.scope ?? []) {
38
+ const to = isExactPath(s) ? map.get(s) : undefined;
39
+ if (to)
40
+ rewrites.push({ kind: "constraints", id: c.id, field: "scope", from: s, to });
41
+ }
42
+ }
43
+ return { rewrites, records: [...new Set(rewrites.map((r) => `${r.kind}:${r.id}`))] };
44
+ }
45
+ /** Apply a plan's rewrites to one decision (pure — returns the healed copy, or the
46
+ * original reference when nothing in the plan touches it). */
47
+ export function repairDecision(d, plan) {
48
+ const mine = plan.rewrites.filter((r) => r.kind === "decisions" && r.id === d.id);
49
+ if (!mine.length)
50
+ return d;
51
+ const sub = (field, value) => mine.find((r) => r.field === field && r.from === value)?.to ?? value;
52
+ return {
53
+ ...d,
54
+ related_files: (d.related_files ?? []).map((f) => sub("related_files", f)),
55
+ rejected_tripwires: (d.rejected_tripwires ?? []).map((tw) => ({
56
+ ...tw,
57
+ scope: (tw.scope ?? []).map((s) => sub("tripwire.scope", s)),
58
+ })),
59
+ };
60
+ }
61
+ /** Apply a plan's rewrites to one constraint (pure). */
62
+ export function repairConstraint(c, plan) {
63
+ const mine = plan.rewrites.filter((r) => r.kind === "constraints" && r.id === c.id);
64
+ if (!mine.length)
65
+ return c;
66
+ return {
67
+ ...c,
68
+ scope: (c.scope ?? []).map((s) => mine.find((r) => r.field === "scope" && r.from === s)?.to ?? s),
69
+ };
70
+ }
71
+ //# sourceMappingURL=repair.js.map
@@ -24,6 +24,17 @@ export function parseSynth(evidence) {
24
24
  }
25
25
  /** Grounded-ness at/above which a Critic-verified draft is a "quick yes". */
26
26
  export const READY_MIN_GROUNDED = 0.7;
27
+ /** Whether a decision is an un-vouched draft still awaiting a human — the ONLY thing
28
+ * the review path surfaces under the auto-trust model.
29
+ *
30
+ * Low confidence NO LONGER makes a draft: captured memory is trusted-advisory the
31
+ * moment it lands (status `accepted`, source `llm_draft`), so it grounds and ranks
32
+ * but never nags. Only a DELIBERATE, not-yet-human-vouched `proposed` record — an
33
+ * explicit roadmap/intent entry a human hasn't confirmed — counts as a review draft.
34
+ * (Enforcement authority is granted INLINE, not by draining a background queue.) */
35
+ export function isReviewDraft(d) {
36
+ return d.status === "proposed" && !d.provenance.source.includes("human_confirmed");
37
+ }
27
38
  /** A draft is "ready to confirm" only when the Critic actually audited it (source
28
39
  * includes "verified") AND judged it well-grounded. A high confidence number alone
29
40
  * is NOT enough — an un-audited draft always needs human eyes. */
@@ -0,0 +1,115 @@
1
+ /** A constraint that actually has teeth: active, blocking, and human-vouched — the
2
+ * same "ARMED" definition `hunch status` uses. Draft/advisory rules don't count as
3
+ * locked stock. */
4
+ function isLocked(c) {
5
+ const src = c.provenance?.source;
6
+ const vouched = !!src && (src.includes("human_confirmed") || src === "derived");
7
+ return c.status === "active" && c.severity === "blocking" && vouched;
8
+ }
9
+ function eventMs(e) {
10
+ const t = Date.parse(e.at);
11
+ return Number.isNaN(t) ? 0 : t;
12
+ }
13
+ /** Tally the four return metrics over a set of events. `bugs_reprevented` is the
14
+ * strict, deterministic claim: a blocked edit whose enforcing decision was caused
15
+ * by a bug that has DEMONSTRABLY regressed (came back once already). No event ever
16
+ * fabricates that shape — it's a real join over the graph. */
17
+ function tallyReturn(events, decById, bugById) {
18
+ let caught = 0, drifts = 0, reprevented = 0, vetoes = 0;
19
+ for (const e of events) {
20
+ if (e.kind === "constraint" || e.kind === "veto" || e.kind === "conformance")
21
+ caught++;
22
+ if (e.kind === "veto")
23
+ vetoes++;
24
+ if (e.kind === "drift")
25
+ drifts++;
26
+ if (e.decision) {
27
+ const d = decById.get(e.decision);
28
+ const bug = d?.caused_by_bug ? bugById.get(d.caused_by_bug) : undefined;
29
+ if (bug && bug.status === "regressed")
30
+ reprevented++;
31
+ }
32
+ }
33
+ return { violations_caught: caught, drifts_flagged: drifts, bugs_reprevented: reprevented, vetoes_fired: vetoes };
34
+ }
35
+ export function computeStats(input) {
36
+ const { decisions, constraints, bugs, componentIds, events } = input;
37
+ const byStatus = (recs, s) => recs.filter((r) => r.status === s).length;
38
+ const activeConstraints = constraints.filter((c) => c.status === "active");
39
+ const locked = activeConstraints.filter(isLocked).length;
40
+ // Coverage: distinct components named by at least one decision, over total. Only
41
+ // component ids that actually EXIST count (a dangling ref isn't coverage).
42
+ const compSet = new Set(componentIds);
43
+ const touched = new Set();
44
+ for (const d of decisions)
45
+ for (const c of d.related_components)
46
+ if (compSet.has(c))
47
+ touched.add(c);
48
+ const componentsTotal = componentIds.length;
49
+ const pct = componentsTotal ? touched.size / componentsTotal : 0;
50
+ const decById = new Map(decisions.map((d) => [d.id, d]));
51
+ const bugById = new Map(bugs.map((b) => [b.id, b]));
52
+ const inWindow = events.filter((e) => eventMs(e) >= input.windowStart);
53
+ const lifetime = tallyReturn(events, decById, bugById);
54
+ const windowReturn = tallyReturn(inWindow, decById, bugById);
55
+ const rulesRecorded = locked;
56
+ const catchesLifetime = lifetime.violations_caught;
57
+ return {
58
+ schema: "hunch.stats/1",
59
+ generated_at: new Date(input.now).toISOString(),
60
+ window: { since: input.windowLabel, from: new Date(input.windowStart).toISOString(), to: new Date(input.now).toISOString() },
61
+ stock: {
62
+ decisions: {
63
+ total: decisions.length,
64
+ accepted: byStatus(decisions, "accepted"),
65
+ superseded: byStatus(decisions, "superseded"),
66
+ proposed: byStatus(decisions, "proposed"),
67
+ rejected: byStatus(decisions, "rejected"),
68
+ },
69
+ invariants: { total: activeConstraints.length, locked, advisory: activeConstraints.length - locked, stale: input.staleConstraints },
70
+ components: componentsTotal,
71
+ bugs: {
72
+ total: bugs.length,
73
+ open: byStatus(bugs, "open"),
74
+ investigating: byStatus(bugs, "investigating"),
75
+ fixed: byStatus(bugs, "fixed"),
76
+ regressed: byStatus(bugs, "regressed"),
77
+ },
78
+ runbooks: input.runbooksCount,
79
+ coverage: { components_with_decision: touched.size, components_total: componentsTotal, pct: Math.round(pct * 100) / 100 },
80
+ },
81
+ return: { window: windowReturn, lifetime },
82
+ compounding: {
83
+ rules_recorded: rulesRecorded,
84
+ catches_lifetime: catchesLifetime,
85
+ payback_ratio: rulesRecorded ? Math.round((catchesLifetime / rulesRecorded) * 10) / 10 : 0,
86
+ },
87
+ };
88
+ }
89
+ /** Human-readable receipt for the terminal (the CLI's default, non-`--json` output).
90
+ * Same three blocks as the JSON: stock → return → compounding. Honest zeros are
91
+ * shown plainly, never hidden — a young graph reads as young, not as broken. */
92
+ export function formatStats(s) {
93
+ const L = [];
94
+ const pct = Math.round(s.stock.coverage.pct * 100);
95
+ L.push(`\n🧠 Hunch — engineering-memory stats\n`);
96
+ L.push(` STOCK (accumulated)`);
97
+ L.push(` decisions: ${s.stock.decisions.total} (${s.stock.decisions.accepted} accepted · ${s.stock.decisions.proposed} proposed · ${s.stock.decisions.superseded} superseded)`);
98
+ L.push(` invariants: ${s.stock.invariants.total} (${s.stock.invariants.locked} locked · ${s.stock.invariants.advisory} advisory${s.stock.invariants.stale ? ` · ${s.stock.invariants.stale} stale` : ""})`);
99
+ L.push(` components: ${s.stock.components} · bugs: ${s.stock.bugs.total} · runbooks: ${s.stock.runbooks}`);
100
+ L.push(` coverage: ${pct}% of components explained by ≥1 decision (${s.stock.coverage.components_with_decision}/${s.stock.coverage.components_total})`);
101
+ L.push(`\n RETURN (what the stock caught)`);
102
+ const r = s.return.lifetime, w = s.return.window;
103
+ L.push(` this ${s.window.since}: ${w.violations_caught} caught · ${w.drifts_flagged} drift · ${w.bugs_reprevented} re-prevented`);
104
+ L.push(` lifetime: ${r.violations_caught} caught · ${r.bugs_reprevented} re-prevented`);
105
+ L.push(`\n COMPOUNDING`);
106
+ if (s.compounding.rules_recorded && s.compounding.catches_lifetime) {
107
+ L.push(` ${s.compounding.rules_recorded} locked rule(s) → ${s.compounding.catches_lifetime} catch(es) · each rule pays back ${s.compounding.payback_ratio}×`);
108
+ }
109
+ else {
110
+ L.push(` ${s.compounding.rules_recorded} locked rule(s), ${s.compounding.catches_lifetime} catch(es) so far — the catch-log grows as the gate fires (nothing to inflate).`);
111
+ }
112
+ L.push("");
113
+ return L.join("\n");
114
+ }
115
+ //# sourceMappingURL=stats.js.map