@davesheffer/hunch 1.20.3 → 1.21.1

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 CHANGED
@@ -174,7 +174,7 @@ repository, separate from the code repository. Hunch does not host it. Create a
174
174
  that every teammate can access, install Hunch on team machines and CI, then have one maintainer run:
175
175
 
176
176
  ```bash
177
- npm i -g @davesheffer/hunch@1.20.3
177
+ npm i -g @davesheffer/hunch@1.21.1
178
178
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
179
179
  git add .gitignore .hunch/team.json
180
180
  git commit -m "chore: connect shared Hunch memory"
@@ -189,7 +189,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
189
189
  After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
190
190
 
191
191
  ```bash
192
- npm i -g @davesheffer/hunch@1.20.3
192
+ npm i -g @davesheffer/hunch@1.21.1
193
193
  git pull
194
194
  hunch init
195
195
  hunch doctor
@@ -237,7 +237,7 @@ but stops automatic memory commits and pushes. As a team-coordinated rollback, r
237
237
  commit to stop discovery after teammates pull the revert. Existing machines retain their ignored
238
238
  local overlay until they are deliberately disconnected; do not delete the memory repo as part of a
239
239
  rollback. For this rollout, reinstall the previous published package with
240
- `npm i -g @davesheffer/hunch@1.20.2`; the release receipt resolves and records the verified rollback
240
+ `npm i -g @davesheffer/hunch@1.20.3`; the release receipt resolves and records the verified rollback
241
241
  target from the npm registry instead of trusting Git tags. Pause enforcement first as shown above,
242
242
  and keep every team client on the same release before resuming Matrix policy workflows.
243
243
 
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { Command } from "commander";
4
+ import { discoverProjectDna, evaluateProjectDnaMatch } from "../core/projectDna.js";
5
+ import { diffProjectDna } from "../core/projectDnaDelta.js";
6
+ import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
7
+ function gitRoot() {
8
+ try {
9
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
10
+ encoding: "utf8",
11
+ stdio: ["ignore", "pipe", "pipe"],
12
+ timeout: 5_000,
13
+ }).trim();
14
+ }
15
+ catch {
16
+ throw new Error("Project DNA must run inside a Git repository");
17
+ }
18
+ }
19
+ function printProfile(root, revision, json) {
20
+ const profile = discoverProjectDna(root, revision);
21
+ if (json) {
22
+ process.stdout.write(`${JSON.stringify(profile, null, 2)}\n`);
23
+ return;
24
+ }
25
+ process.stdout.write(`Project DNA ${profile.profile_id}\n`);
26
+ process.stdout.write(`revision: ${profile.repository_revision}\n`);
27
+ process.stdout.write(`history sample: ${profile.history_sample_count}\n`);
28
+ process.stdout.write(`committed convention files: ${profile.source_files.length ? profile.source_files.join(", ") : "none"}\n`);
29
+ if (!profile.traits.length) {
30
+ process.stdout.write("traits: none yet (insufficient evidence)\n");
31
+ return;
32
+ }
33
+ for (const trait of profile.traits) {
34
+ process.stdout.write(`- [${trait.category}] ${trait.claim} (${trait.confidence.toFixed(2)}; ${trait.id})\n`);
35
+ }
36
+ }
37
+ const program = new Command();
38
+ program
39
+ .name("hunch-dna")
40
+ .description("Derive and inspect Hunch Project DNA from exact committed repository evidence.")
41
+ .option("--revision <ref>", "Git revision to inspect", "HEAD")
42
+ .option("--json", "emit the sealed machine-readable profile")
43
+ .action((options) => {
44
+ printProfile(gitRoot(), options.revision, !!options.json);
45
+ });
46
+ program
47
+ .command("match")
48
+ .description("Score a commit/PR/issue/message against deterministic applicable DNA traits.")
49
+ .requiredOption("--kind <kind>", "commit | pull_request | issue | message")
50
+ .requiredOption("--title <title>", "artifact title/subject")
51
+ .option("--body <body>", "artifact body")
52
+ .option("--revision <ref>", "Git revision whose DNA applies", "HEAD")
53
+ .option("--json", "emit the sealed match contract")
54
+ .action((options) => {
55
+ if (!(new Set(["commit", "pull_request", "issue", "message"])).has(options.kind)) {
56
+ throw new Error("--kind must be commit, pull_request, issue, or message");
57
+ }
58
+ const artifact = {
59
+ kind: options.kind,
60
+ title: options.title,
61
+ ...(options.body !== undefined ? { body: options.body } : {}),
62
+ };
63
+ const globals = program.opts();
64
+ const revision = options.revision ?? globals.revision ?? "HEAD";
65
+ const json = options.json ?? globals.json ?? false;
66
+ const profile = discoverProjectDna(gitRoot(), revision);
67
+ const match = evaluateProjectDnaMatch(profile, artifact);
68
+ if (json)
69
+ process.stdout.write(`${JSON.stringify(match, null, 2)}\n`);
70
+ else {
71
+ process.stdout.write(`Project DNA match: ${match.score === null ? "n/a" : `${match.score}%`} (${match.applicable_checks} applicable checks)\n`);
72
+ for (const check of match.checks.filter((item) => item.applicable)) {
73
+ process.stdout.write(`- ${check.passed ? "PASS" : "FAIL"} ${check.key}: ${check.detail}\n`);
74
+ }
75
+ }
76
+ });
77
+ program
78
+ .command("context")
79
+ .description("Render the bounded advisory DNA supplement intended for Hunch delivery/context assembly.")
80
+ .option("--revision <ref>", "Git revision to inspect", "HEAD")
81
+ .option("--traits <count>", "maximum DNA traits in the orientation slice", "8")
82
+ .option("--json", "emit supplement JSON")
83
+ .action((options) => {
84
+ const globals = program.opts();
85
+ const revision = options.revision ?? globals.revision ?? "HEAD";
86
+ const json = options.json ?? globals.json ?? false;
87
+ const cap = Number(options.traits);
88
+ const supplement = projectDnaDeliverySupplement(discoverProjectDna(gitRoot(), revision), cap);
89
+ if (json)
90
+ process.stdout.write(`${JSON.stringify(supplement, null, 2)}\n`);
91
+ else
92
+ process.stdout.write(`${supplement?.text ?? "No evidence-backed Project DNA traits yet."}\n`);
93
+ });
94
+ program
95
+ .command("diff")
96
+ .description("Compare DNA at two exact revisions; reports observation drift without inferring causality.")
97
+ .argument("<from>", "older Git revision")
98
+ .argument("<to>", "newer Git revision")
99
+ .option("--json", "emit the sealed delta contract")
100
+ .action((from, to, options) => {
101
+ const root = gitRoot();
102
+ const delta = diffProjectDna(discoverProjectDna(root, from), discoverProjectDna(root, to));
103
+ const json = options.json ?? program.opts().json ?? false;
104
+ if (json)
105
+ process.stdout.write(`${JSON.stringify(delta, null, 2)}\n`);
106
+ else {
107
+ process.stdout.write(`Project DNA drift ${delta.delta_id}: ${delta.changed ? `${delta.changes.length} change(s)` : "no observed change"}\n`);
108
+ for (const change of delta.changes)
109
+ process.stdout.write(`- ${change.kind}: ${change.key}\n`);
110
+ }
111
+ });
112
+ program.parseAsync().catch((error) => {
113
+ process.stderr.write(`hunch-dna: ${error instanceof Error ? error.message : String(error)}\n`);
114
+ process.exitCode = 1;
115
+ });
116
+ //# sourceMappingURL=dna.js.map
package/dist/cli/index.js CHANGED
@@ -57,6 +57,9 @@ import { compileVerifiedEvidenceMap, formatVerifiedEvidenceMap } from "../core/e
57
57
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
58
58
  import { buildDeliveryEnvelope, DELIVERY_PROFILES } from "../core/delivery.js";
59
59
  import { deriveChangeIdentity } from "../core/changeIdentity.js";
60
+ import { discoverProjectDna, evaluateProjectDnaMatch } from "../core/projectDna.js";
61
+ import { diffProjectDna } from "../core/projectDnaDelta.js";
62
+ import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
60
63
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
61
64
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
62
65
  import { isHumanConfirmed } from "../core/strictgate.js";
@@ -306,7 +309,7 @@ program
306
309
  // Claude's native hooks run alongside provider-specific hooks below. Every
307
310
  // adapter reads firmness at run time, so changing it needs no config rewrite.
308
311
  if (opts.agentHooks !== false) {
309
- const a = installClaudeHooks(root, `${inv.shell} hook`);
312
+ const a = installClaudeHooks(root, `${inv.agentHookShell} hook`);
310
313
  console.log(` ✓ Claude Code agent hooks ${a.action} (firmness: ${firmness} — change with \`hunch firmness <level>\`)`);
311
314
  }
312
315
  // Multi-assistant compatibility: MCP + grounding + lifecycle adapters share
@@ -2903,6 +2906,114 @@ landscapeCmd
2903
2906
  console.log(` hunch landscape adopt --ref ${discovery.sourceRevision} --expected ${discovery.discoveryHash} --all --reviewed-by <you>${discovery.issues.length ? " --acknowledge-issues" : ""}`);
2904
2907
  console.log("Or pass --candidate <hash...> to adopt an explicit subset; relationships require both endpoint resources.");
2905
2908
  });
2909
+ // ---- dna (read-only, exact-revision repository convention profile) -------
2910
+ const dnaCmd = program
2911
+ .command("dna")
2912
+ .description("Inspect evidence-backed Project DNA and evaluate repository-native artifacts without writing graph authority.");
2913
+ dnaCmd
2914
+ .command("inspect")
2915
+ .description("Derive a deterministic Project DNA profile from one exact Git revision. This command never writes memory.")
2916
+ .option("--ref <ref>", "Git commit/ref to inspect", "HEAD")
2917
+ .option("--json", "emit the complete machine-readable profile")
2918
+ .action((opts) => {
2919
+ try {
2920
+ const profile = discoverProjectDna(findRoot(), opts.ref);
2921
+ if (opts.json) {
2922
+ console.log(JSON.stringify(profile, null, 2));
2923
+ return;
2924
+ }
2925
+ console.log(`Project DNA at ${profile.repository_revision}`);
2926
+ console.log(`Profile: ${profile.profile_id} (${profile.content_hash})`);
2927
+ console.log(`Evidence: ${profile.history_sample_count} commit subject(s), ${profile.source_files.length} convention file(s)`);
2928
+ if (profile.source_files.length)
2929
+ console.log(`Sources: ${profile.source_files.join(", ")}`);
2930
+ console.log(`\nTRAITS (${profile.traits.length})`);
2931
+ for (const trait of profile.traits) {
2932
+ console.log(` ${trait.id} [${trait.category}/${trait.confidence.toFixed(3)}] ${trait.claim}`);
2933
+ console.log(` ${trait.evidence.map((evidence) => `${evidence.ref} @ ${evidence.revision.slice(0, 12)} (${evidence.content_hash})`).join("; ")}`);
2934
+ }
2935
+ console.log("\nAdvisory observation only; no trait was adopted into graph authority.");
2936
+ }
2937
+ catch (error) {
2938
+ fail(error instanceof Error ? error.message : String(error));
2939
+ }
2940
+ });
2941
+ dnaCmd
2942
+ .command("match")
2943
+ .description("Explainably score a commit, PR, issue, or message against deterministic checks in an exact-revision DNA profile.")
2944
+ .requiredOption("--kind <kind>", "artifact kind: commit, pull_request, issue, or message")
2945
+ .requiredOption("--title <title>", "artifact title or commit subject")
2946
+ .option("--body <body>", "artifact body (for example a PR description)")
2947
+ .option("--ref <ref>", "Git commit/ref whose DNA should be used", "HEAD")
2948
+ .option("--json", "emit the complete machine-readable match envelope")
2949
+ .action((opts) => {
2950
+ try {
2951
+ if (!["commit", "pull_request", "issue", "message"].includes(opts.kind)) {
2952
+ return fail("--kind must be commit, pull_request, issue, or message");
2953
+ }
2954
+ const profile = discoverProjectDna(findRoot(), opts.ref);
2955
+ const match = evaluateProjectDnaMatch(profile, {
2956
+ kind: opts.kind,
2957
+ title: opts.title,
2958
+ body: opts.body,
2959
+ });
2960
+ if (opts.json) {
2961
+ console.log(JSON.stringify(match, null, 2));
2962
+ return;
2963
+ }
2964
+ console.log(`Project Match: ${match.score === null ? "not enough deterministic checks" : `${match.score.toFixed(1)}/100`}`);
2965
+ console.log(`Profile: ${match.profile_id} @ ${match.repository_revision}`);
2966
+ for (const check of match.checks.filter((candidate) => candidate.applicable)) {
2967
+ console.log(` ${check.passed ? "PASS" : "FAIL"} ${check.key} — ${check.detail}`);
2968
+ }
2969
+ console.log("Advisory only; the score does not grant or change enforcement authority.");
2970
+ }
2971
+ catch (error) {
2972
+ fail(error instanceof Error ? error.message : String(error));
2973
+ }
2974
+ });
2975
+ dnaCmd
2976
+ .command("context")
2977
+ .description("Render the bounded advisory Project DNA supplement used by normal Hunch context delivery.")
2978
+ .option("--ref <ref>", "Git commit/ref to inspect", "HEAD")
2979
+ .option("--traits <count>", "maximum traits in the orientation slice", "8")
2980
+ .option("--json", "emit the complete machine-readable supplement")
2981
+ .action((opts) => {
2982
+ try {
2983
+ const traitCap = Number(opts.traits);
2984
+ const supplement = projectDnaDeliverySupplement(discoverProjectDna(findRoot(), opts.ref), traitCap);
2985
+ if (opts.json)
2986
+ console.log(JSON.stringify(supplement, null, 2));
2987
+ else
2988
+ console.log(supplement?.text ?? "No evidence-backed Project DNA traits yet.");
2989
+ }
2990
+ catch (error) {
2991
+ fail(error instanceof Error ? error.message : String(error));
2992
+ }
2993
+ });
2994
+ dnaCmd
2995
+ .command("diff")
2996
+ .description("Compare two exact-revision DNA profiles without rewriting either snapshot.")
2997
+ .argument("<from>", "older Git revision")
2998
+ .argument("<to>", "newer Git revision")
2999
+ .option("--json", "emit the complete sealed delta")
3000
+ .action((from, to, opts) => {
3001
+ try {
3002
+ const root = findRoot();
3003
+ const delta = diffProjectDna(discoverProjectDna(root, from), discoverProjectDna(root, to));
3004
+ if (opts.json) {
3005
+ console.log(JSON.stringify(delta, null, 2));
3006
+ return;
3007
+ }
3008
+ console.log(`Project DNA drift ${delta.delta_id}: ${delta.changed ? `${delta.changes.length} change(s)` : "no observed change"}`);
3009
+ for (const change of delta.changes)
3010
+ console.log(` ${change.kind} ${change.key}`);
3011
+ console.log("Observation only; no Project DNA profile or graph authority was rewritten.");
3012
+ }
3013
+ catch (error) {
3014
+ fail(error instanceof Error ? error.message : String(error));
3015
+ }
3016
+ });
2906
3017
  landscapeCmd
2907
3018
  .command("adopt")
2908
3019
  .description("Human-confirm a reviewed candidate set and write only those exact records through Hunch's normal graph boundary.")
@@ -16,6 +16,13 @@ export function dim(s) {
16
16
  export function publishedMcpInvocation() {
17
17
  return { command: "npx", args: ["-y", `--package=${HUNCH_NPX_PACKAGE_SPEC}`, "hunch"] };
18
18
  }
19
+ /** Render a structured invocation for the host shell. Safe tokens stay bare so
20
+ * PowerShell can execute the first token; paths and other unsafe tokens use
21
+ * JSON string quoting, which is accepted by PowerShell, cmd, and POSIX sh. */
22
+ export function shellInvocation(inv) {
23
+ const token = (part) => /^[A-Za-z0-9_@:=+.,/-]+$/.test(part) ? part : JSON.stringify(part);
24
+ return [inv.command, ...inv.args].map(token).join(" ");
25
+ }
19
26
  /** The doctor command's synthesis-status line(s) for a resolved provider.
20
27
  * Exported for testing — the previous version (a bare provider-name switch,
21
28
  * before the resolveSynthesisProvider preference system existed) had zero
@@ -77,23 +84,29 @@ export function resolveInvocation() {
77
84
  // absolute-node invocation below.
78
85
  const installed = !isDev && entry.replace(/\\/g, "/").includes("/node_modules/");
79
86
  if (installed) {
87
+ const mcp = publishedMcpInvocation();
80
88
  return {
81
89
  shell: `${q(process.execPath)} ${q(entry)}`,
82
- mcp: publishedMcpInvocation(),
90
+ agentHookShell: shellInvocation(mcp),
91
+ mcp,
83
92
  };
84
93
  }
85
94
  if (isDev) {
95
+ const mcp = { command: "npx", args: ["tsx", entry] };
86
96
  return {
87
97
  shell: `npx tsx ${q(entry)}`,
88
- mcp: { command: "npx", args: ["tsx", entry] },
98
+ agentHookShell: shellInvocation(mcp),
99
+ mcp,
89
100
  };
90
101
  }
91
102
  // Source-checkout dist run (e.g. `node dist/cli/index.js`, npm link): inherently
92
103
  // per-machine. Use the absolute node binary (process.execPath) rather than a bare
93
104
  // `node`, so the hook works even when nvm's `node` isn't on the hook's PATH.
105
+ const mcp = { command: process.execPath, args: [entry] };
94
106
  return {
95
107
  shell: `${q(process.execPath)} ${q(entry)}`,
96
- mcp: { command: process.execPath, args: [entry] },
108
+ agentHookShell: shellInvocation(mcp),
109
+ mcp,
97
110
  };
98
111
  }
99
112
  //# sourceMappingURL=invocation.js.map