@davesheffer/hunch 1.20.0-rc.2 → 1.20.0-rc.4

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
@@ -58,11 +58,13 @@ existing configuration.
58
58
  passes, needs attention, or should be blocked.
59
59
  - **Past bugs stay useful** — see which old incident a piece of code fixed before accidentally
60
60
  undoing it.
61
- - **Understands how code connects** — for TypeScript, JavaScript, Python, Go, YAML, and Helm, Hunch
61
+ - **Understands how code connects** — for TypeScript, JavaScript, Python, Go, PHP, YAML, and Helm, Hunch
62
62
  can see what calls or depends on the code you are about to change. Its memory works with any
63
63
  language.
64
64
  - **Works with existing decision documents** — import your architecture decision records into
65
- Hunch, or export Hunch decisions back to a standard format other tools can read.
65
+ Hunch, or export Hunch decisions back to a standard format other tools can read. Imported ADRs
66
+ start as useful advisory memory; during normal work your assistant asks you to approve or decline
67
+ one exact ADR at a time. Silence never grants authority, and changed ADR text is asked again.
66
68
 
67
69
  The source of truth is readable JSON in `.hunch/`. A local SQLite index makes retrieval fast but
68
70
  is always rebuildable.
@@ -98,6 +100,7 @@ Most memory work happens automatically after commits. These commands cover the c
98
100
  | `hunch check --working` | Check current changes against the decisions and rules your team trusts |
99
101
  | `hunch log` | See what Hunch remembered and undo a memory change if needed |
100
102
  | `hunch escalations` | See the rare questions that need a human answer |
103
+ | `hunch review` | Answer the current imported-ADR approve/decline question from the terminal |
101
104
  | `hunch doctor` | Diagnose setup problems |
102
105
 
103
106
  <details>
package/dist/cli/index.js CHANGED
@@ -31,7 +31,7 @@ import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/index
31
31
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
32
32
  import { parseTestReport } from "../extractors/testreport.js";
33
33
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
34
- import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges } from "../extractors/git.js";
34
+ import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges } from "../extractors/git.js";
35
35
  import { parseMemoryLog } from "../core/memorylog.js";
36
36
  import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
37
37
  import { planPolicyRepair, repairPolicySpec } from "../constitution/repairPolicies.js";
@@ -75,6 +75,7 @@ import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWiki
75
75
  import { adoptProsePrompt } from "../wiki/adopt.js";
76
76
  import { topicCollisions, isInForce, liveForTopic } from "../core/topics.js";
77
77
  import { ADR_DIR_CANDIDATES, ADR_FILE_RE, mapAdrCorpus } from "../extractors/adrImport.js";
78
+ import { applyImportedAdrReview, carryImportedAdrReview, importedAdrReviewHash, importedAdrSourceHash, isImportedAdrDecision, pendingImportedAdrReviews } from "../core/importReview.js";
78
79
  import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
79
80
  import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
80
81
  import { pendingEscalations, policyEscalations } from "../core/escalations.js";
@@ -217,6 +218,10 @@ program
217
218
  const res = indexRepo(store, root, { source: { kind: "commit", ref: "HEAD" } });
218
219
  store.reindex();
219
220
  console.log(` ✓ indexed committed HEAD (${res.files} files) → ${res.symbols} symbols, ${res.edges} edges, ${res.components} components`);
221
+ for (const item of res.coverage) {
222
+ const reasons = Object.entries(item.reasons).map(([reason, count]) => `${reason}:${count}`).join(", ");
223
+ console.log(` coverage ${item.language}: ${item.eligible} eligible · ${item.parsed} parsed · ${item.skipped} skipped${reasons ? ` (${reasons})` : ""}`);
224
+ }
220
225
  if (res.skipped)
221
226
  console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
222
227
  }
@@ -351,6 +356,10 @@ program
351
356
  const healed = shouldAutoCommit ? [] : refreshExistingGrounding(root, store);
352
357
  console.log(`Indexed ${res.files} files:`);
353
358
  console.log(` ${counts.symbols} symbols, ${counts.edges} edges, ${counts.components} components`);
359
+ for (const item of res.coverage) {
360
+ const reasons = Object.entries(item.reasons).map(([reason, count]) => `${reason}:${count}`).join(", ");
361
+ console.log(` coverage ${item.language}: ${item.eligible} eligible · ${item.parsed} parsed · ${item.skipped} skipped${reasons ? ` (${reasons})` : ""}`);
362
+ }
354
363
  if (correctionSweep.scanned) {
355
364
  console.log(` correction reviews: ${correctionSweep.proved} proved · ${correctionSweep.already_proved} current · ${correctionSweep.pending} pending · ${correctionSweep.legacy_only} legacy-only · ${correctionSweep.conflicted} conflicted · ${correctionSweep.failed.length} failed; authority none`);
356
365
  }
@@ -3110,7 +3119,16 @@ program
3110
3119
  const files = readdirSync(join(root, dir)).filter((f) => ADR_FILE_RE.test(f)).sort();
3111
3120
  if (!files.length)
3112
3121
  return fail(`no NNNN-slug.md ADR files in ${dir}`);
3113
- const sources = files.map((f) => ({ relPath: `${dir}/${f}`, text: readFileSync(join(root, dir, f), "utf8") }));
3122
+ const sources = files.map((f) => {
3123
+ const relPath = `${dir}/${f}`;
3124
+ const sourceRevision = firstCommitForFile(relPath, root) || null;
3125
+ return {
3126
+ relPath,
3127
+ text: readFileSync(join(root, dir, f), "utf8"),
3128
+ sourceDate: sourceRevision ? (asOfDate(sourceRevision, root) ?? null) : null,
3129
+ sourceRevision,
3130
+ };
3131
+ });
3114
3132
  const { decisions, warnings } = mapAdrCorpus(sources);
3115
3133
  for (const w of warnings)
3116
3134
  console.log(` ⚠ ${w}`);
@@ -3136,8 +3154,12 @@ program
3136
3154
  }
3137
3155
  store.json.ensureDirs();
3138
3156
  let created = 0, updated = 0;
3139
- for (const d of decisions) {
3140
- if (store.getRec("decisions", d.id))
3157
+ for (let index = 0; index < decisions.length; index++) {
3158
+ const candidate = decisions[index];
3159
+ const previous = opts.private ? store.getRec("decisions", candidate.id) : store.json.get("decisions", candidate.id);
3160
+ const d = carryImportedAdrReview(previous, candidate);
3161
+ decisions[index] = d;
3162
+ if (previous)
3141
3163
  updated++;
3142
3164
  else
3143
3165
  created++;
@@ -3150,6 +3172,18 @@ program
3150
3172
  const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: import ${decisions.length} ADR(s) from ${dir}`);
3151
3173
  const live = decisions.filter((d) => d.status === "accepted").length;
3152
3174
  console.log(`✓ imported ${decisions.length} ADR(s) from ${dir} (${created} new, ${updated} updated; ${live} live, ${decisions.length - live} historical)${opts.private ? " [private overlay]" : ""}`);
3175
+ const pending = pendingImportedAdrReviews(decisions);
3176
+ if (pending.length) {
3177
+ const first = pending[0];
3178
+ const sourceHash = importedAdrSourceHash(first);
3179
+ const reviewHash = importedAdrReviewHash(first);
3180
+ console.log(`\n⚖ HUMAN REVIEW ${pending.length} imported live ADR(s) are advisory until you answer.`);
3181
+ console.log(` “${first.title}” (${first.id})`);
3182
+ console.log(" Approve it as human-confirmed project authority, or decline and keep it advisory?");
3183
+ console.log(` Approve: hunch review --approve-import ${first.id} --expected-source-hash ${sourceHash} --expected-review-hash ${reviewHash} --reviewed-by <you>`);
3184
+ console.log(` Decline: hunch review --decline-import ${first.id} --expected-source-hash ${sourceHash} --expected-review-hash ${reviewHash} --reviewed-by <you>`);
3185
+ console.log(" Your coding assistant will also ask this inline in the next Hunch session; silence never approves it.");
3186
+ }
3153
3187
  if (flush === "pushed")
3154
3188
  console.log(" ↳ private memory committed + pushed");
3155
3189
  }
@@ -3952,6 +3986,7 @@ program
3952
3986
  const precise = blocking.filter((c) => !!effectiveForbids(c));
3953
3987
  const scopeOnly = blocking.filter((c) => !effectiveForbids(c));
3954
3988
  const drafts = store.json.loadAll("decisions").filter(isReviewDraft);
3989
+ const importedReviews = pendingImportedAdrReviews(store.json.loadAll("decisions"));
3955
3990
  const { ready, scrutiny } = partitionReview(drafts, READY_MIN_GROUNDED);
3956
3991
  const stale = store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint");
3957
3992
  const fnote = {
@@ -3970,6 +4005,9 @@ program
3970
4005
  if (ready.length || scrutiny.length) {
3971
4006
  console.log(`\n ⏳ TO CONFIRM ${ready.length} ready · ${scrutiny.length} need scrutiny → hunch review${ready.length ? " --accept-verified" : ""}`);
3972
4007
  }
4008
+ if (importedReviews.length) {
4009
+ console.log(`\n ⚖ ADR REVIEW ${importedReviews.length} imported live ADR(s) need an approve/decline answer → hunch review`);
4010
+ }
3973
4011
  if (stale.length) {
3974
4012
  console.log(`\n ♻ STALE ${stale.length} rule(s) whose guarded code moved since last verified → re-confirm to keep the teeth`);
3975
4013
  }
@@ -4573,9 +4611,14 @@ program
4573
4611
  });
4574
4612
  program
4575
4613
  .command("review")
4576
- .description("Triage drafts: segmented list, accept/reject one, or batch-accept Critic-verified drafts.")
4614
+ .description("Answer hash-bound imported-ADR questions, or triage deliberate proposed drafts.")
4577
4615
  .option("--accept <id>", "promote a decision to accepted/human-confirmed (confirms its tripwires)")
4578
4616
  .option("--reject <id>", "reject a draft decision with a durable lifecycle tombstone")
4617
+ .option("--approve-import <id>", "approve one exact imported ADR as human-confirmed authority")
4618
+ .option("--decline-import <id>", "record that one exact imported ADR was reviewed and must stay advisory")
4619
+ .option("--expected-source-hash <hash>", "exact sha256 source hash printed with the imported-ADR question")
4620
+ .option("--expected-review-hash <hash>", "exact sha256 mapped-meaning hash printed with the imported-ADR question")
4621
+ .option("--reviewed-by <label>", "credential-free human reviewer label for an imported-ADR answer")
4579
4622
  .option("--accept-verified", "batch-accept every Critic-verified, well-grounded draft (>= --min-grounded)")
4580
4623
  .option("--reject-duplicates", "batch-reject drafts that near-duplicate an accepted record (deterministic term+file similarity — hygiene, not judgment)")
4581
4624
  .option("--min-grounded <n>", "grounded-ness threshold for the ready group / --accept-verified", String(READY_MIN_GROUNDED))
@@ -4587,15 +4630,60 @@ program
4587
4630
  store.close();
4588
4631
  return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
4589
4632
  }
4633
+ const actionCount = [opts.accept, opts.reject, opts.approveImport, opts.declineImport, opts.acceptVerified, opts.rejectDuplicates].filter(Boolean).length;
4634
+ if (actionCount > 1) {
4635
+ store.close();
4636
+ return fail("choose exactly one review action at a time");
4637
+ }
4590
4638
  const decisions = () => opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
4591
4639
  let publicGroundingChanged = false;
4592
4640
  const touchedHomes = new Set();
4593
- if (opts.accept) {
4641
+ if (opts.approveImport || opts.declineImport) {
4642
+ const id = opts.approveImport ?? opts.declineImport;
4643
+ if (!opts.expectedSourceHash || !opts.expectedReviewHash || !opts.reviewedBy) {
4644
+ store.close();
4645
+ return fail("an imported-ADR answer requires --expected-source-hash <hash>, --expected-review-hash <hash>, and --reviewed-by <label>");
4646
+ }
4647
+ const d = opts.private ? store.getRec("decisions", id) : store.json.get("decisions", id);
4648
+ if (!d) {
4649
+ store.close();
4650
+ return fail(`decision ${id} not found`);
4651
+ }
4652
+ try {
4653
+ const reviewed = applyImportedAdrReview(d, {
4654
+ disposition: opts.approveImport ? "approve" : "decline",
4655
+ expectedSourceHash: opts.expectedSourceHash,
4656
+ expectedReviewHash: opts.expectedReviewHash,
4657
+ reviewer: opts.reviewedBy,
4658
+ });
4659
+ const home = opts.private ? decisionMemoryHome(store, id) : "public";
4660
+ putDecisionInHome(store, reviewed, home);
4661
+ touchedHomes.add(home);
4662
+ store.reindex();
4663
+ if (home === "public") {
4664
+ publicGroundingChanged = true;
4665
+ if (!store.autoCommit)
4666
+ refreshExistingGrounding(root, store);
4667
+ }
4668
+ console.log(opts.approveImport
4669
+ ? `✓ approved ${id} for exact source ${opts.expectedSourceHash} (now human-confirmed authority)`
4670
+ : `✓ declined authority for ${id} at exact source ${opts.expectedSourceHash} (reviewed; remains advisory)`);
4671
+ }
4672
+ catch (error) {
4673
+ store.close();
4674
+ return fail(error instanceof Error ? error.message : String(error));
4675
+ }
4676
+ }
4677
+ else if (opts.accept) {
4594
4678
  const d = opts.private ? store.getRec("decisions", opts.accept) : store.json.get("decisions", opts.accept);
4595
4679
  if (!d) {
4596
4680
  store.close();
4597
4681
  return fail(`decision ${opts.accept} not found`);
4598
4682
  }
4683
+ if (isImportedAdrDecision(d)) {
4684
+ store.close();
4685
+ return fail(`imported ADR ${d.id} requires the hash-bound --approve-import flow shown by hunch review`);
4686
+ }
4599
4687
  const { source, armed, home } = acceptDecision(store, d, opts.private ? decisionMemoryHome(store, d.id) : "public");
4600
4688
  touchedHomes.add(home);
4601
4689
  store.reindex();
@@ -4668,12 +4756,26 @@ program
4668
4756
  }
4669
4757
  }
4670
4758
  else {
4671
- const drafts = decisions().filter(isReviewDraft);
4759
+ const allDecisions = decisions();
4760
+ const imported = pendingImportedAdrReviews(allDecisions);
4761
+ const drafts = allDecisions.filter(isReviewDraft);
4672
4762
  const { ready, scrutiny } = partitionReview(drafts, minGrounded);
4673
- if (!ready.length && !scrutiny.length) {
4763
+ if (!ready.length && !scrutiny.length && !imported.length) {
4674
4764
  console.log("✓ No drafts awaiting review — captured memory auto-trusts (advisory) the moment it lands.");
4675
4765
  }
4676
4766
  else {
4767
+ if (imported.length) {
4768
+ const d = imported[0];
4769
+ const sourceHash = importedAdrSourceHash(d);
4770
+ const reviewHash = importedAdrReviewHash(d);
4771
+ console.log(`⚖ ${imported.length} imported live ADR(s) need a human answer (one at a time):\n`);
4772
+ console.log(` ${d.id} ${d.title}`);
4773
+ console.log(` ${d.related_files[0] ?? "source unknown"} source ${sourceHash} review ${reviewHash}`);
4774
+ console.log(` ${d.decision.slice(0, 180)}`);
4775
+ console.log("\n Approve as human-confirmed authority, or decline and keep advisory?");
4776
+ console.log(` Approve: hunch review --approve-import ${d.id} --expected-source-hash ${sourceHash} --expected-review-hash ${reviewHash} --reviewed-by <you>`);
4777
+ console.log(` Decline: hunch review --decline-import ${d.id} --expected-source-hash ${sourceHash} --expected-review-hash ${reviewHash} --reviewed-by <you>\n`);
4778
+ }
4677
4779
  if (ready.length) {
4678
4780
  console.log(`✓ ${ready.length} ready to confirm — Critic-verified, grounded ≥ ${minGrounded} (best first):\n`);
4679
4781
  for (const it of ready)
@@ -4905,7 +5007,7 @@ program
4905
5007
  // ---- escalations (the inline "ask the human" surface) ---------------------
4906
5008
  program
4907
5009
  .command("escalations")
4908
- .description("The decisions a human must make NOW — surfaced to be asked INLINE (in the prompt), never a background queue. Captured memory auto-trusts on landing; this lists only what the graph genuinely can't resolve itself: topic conflicts, Constitution candidates awaiting review, and proposed policies whose activation is a human call. Normally empty. Exits non-zero when any are open, so an assistant/CI knows to raise them.")
5010
+ .description("The decisions a human must make NOW — surfaced to be asked INLINE, never inferred: one exact imported ADR at a time, topic conflicts, stale premises, and Constitution activation calls. Normally empty. Exits non-zero when any are open.")
4909
5011
  .option("--json", "emit the escalation entries as JSON (the VS Code panel's data source)")
4910
5012
  .action(async (opts) => {
4911
5013
  const { store, root } = storeFor();
@@ -4,7 +4,7 @@ import { commitChanges, fileAtRef, firstParent, revParse } from "../extractors/g
4
4
  import { attributeCalls, parseSource } from "../extractors/parse.js";
5
5
  import { canonicalHash } from "./canonical.js";
6
6
  import { StructuralDeltaSchema, } from "./schema.js";
7
- const CODE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
7
+ const CODE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|php)$/;
8
8
  const SKIP_SEGMENTS = new Set(["node_modules", ".git", ".hunch", "dist", "build", "coverage", ".next", "out", "vendor"]);
9
9
  const MAX_CODE_FILES = 64;
10
10
  const MAX_SOURCE_BYTES = 8 * 1024 * 1024;
@@ -14,7 +14,7 @@ export const DataClassSchema = z.enum(["public", "private", "secret"]);
14
14
  export const StructuralSymbolRefSchema = z.object({
15
15
  file: z.string().min(1),
16
16
  name: z.string().min(1),
17
- kind: z.enum(["function", "method", "class", "interface", "type", "variable", "file"]),
17
+ kind: z.enum(["function", "method", "class", "interface", "trait", "enum", "type", "variable", "file"]),
18
18
  });
19
19
  export const StructuralCallRefSchema = z.object({
20
20
  file: z.string().min(1),
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { compareCodeUnits } from "./canonicalOrder.js";
2
3
  import { rankIssueImplementationOwners, } from "./pipeline.js";
3
4
  import { compileVerifiedEvidenceMap, } from "./evidenceMap.js";
4
5
  import { buildFileFirstDeclarationClusters, buildProgressiveDeclarationPlan, } from "./declarationClusters.js";
@@ -109,8 +110,10 @@ function terms(value) {
109
110
  * but runtime declarations win ties in the selected layer. */
110
111
  function runtimeDeclarationOwners(sources) {
111
112
  const owners = new Set();
112
- const declaration = /^(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:async\s+)?(?:function|class|enum|const|let|var)\s+([$A-Za-z_][$\w]*)/gm;
113
113
  for (const source of sources) {
114
+ const declaration = source.path.endsWith(".php")
115
+ ? /^(?:(?:#\[[^\r\n]*\])\r?\n)*(?:(?:abstract|final|readonly)\s+)*(?:class|interface|trait|enum|function)\s+&?\s*([A-Za-z_][A-Za-z0-9_]*)/gm
116
+ : /^(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:async\s+)?(?:function|class|enum|const|let|var)\s+([$A-Za-z_][$\w]*)/gm;
114
117
  for (const match of source.content.matchAll(declaration)) {
115
118
  owners.add(`${source.path}::${match[1]}`);
116
119
  }
@@ -149,7 +152,7 @@ export function rankIssueCorrectionStageCandidates(issueValue, sources) {
149
152
  || Number(a.type_scaffolding) - Number(b.type_scaffolding)
150
153
  || b.symbol_overlap - a.symbol_overlap
151
154
  || b.lexical_score - a.lexical_score
152
- || a.owner.localeCompare(b.owner));
155
+ || compareCodeUnits(a.owner, b.owner));
153
156
  // Overloads and repeated declarations can produce the same owner more than
154
157
  // once. A shortlist must spend each slot on a distinct correction candidate.
155
158
  const seen = new Set();
@@ -264,7 +267,7 @@ export function selectGuardedExecutionBridge(baselineOwnersValue, rankedOwnersVa
264
267
  : [];
265
268
  }).sort((left, right) => right.execution_ratio - left.execution_ratio
266
269
  || left.static_rank - right.static_rank
267
- || left.owner.localeCompare(right.owner))[0];
270
+ || compareCodeUnits(left.owner, right.owner))[0];
268
271
  if (direct)
269
272
  return [direct];
270
273
  const maxRatio = evidence.reduce((best, entry) => Math.max(best, entry.ratio), 0);
@@ -283,7 +286,7 @@ export function selectGuardedExecutionBridge(baselineOwnersValue, rankedOwnersVa
283
286
  - Number(left.strategy === "direct-high-contrast-execution")
284
287
  || right.execution_ratio - left.execution_ratio
285
288
  || left.static_rank - right.static_rank
286
- || left.owner.localeCompare(right.owner));
289
+ || compareCodeUnits(left.owner, right.owner));
287
290
  return choices[0] ?? null;
288
291
  }
289
292
  export function reserveExecutionGuidedFileOwner(baselineOwnersValue, rankedOwnersValue, evidenceMap, requestedLimit = CORRECTION_STAGE_CANDIDATE_LIMIT) {
@@ -352,7 +355,7 @@ export function rankIssueAdaptiveCorrectionCandidates(issueValue, sources) {
352
355
  || b.path_overlap - a.path_overlap
353
356
  || b.symbol_overlap - a.symbol_overlap
354
357
  || Number(b.runtime_declaration) - Number(a.runtime_declaration)
355
- || a.owner.localeCompare(b.owner));
358
+ || compareCodeUnits(a.owner, b.owner));
356
359
  const deeper = ranked.filter((candidate) => !candidate.invoked_entrance
357
360
  && (candidate.path_overlap > 0 || candidate.symbol_overlap > 0));
358
361
  return deeper.length ? ranked.filter((candidate) => !candidate.invoked_entrance) : ranked;
@@ -1,4 +1,5 @@
1
1
  import { topicCollisions } from "./topics.js";
2
+ import { importedAdrReviewHash, importedAdrSourceHash, pendingImportedAdrReviews } from "./importReview.js";
2
3
  /** The decisions a human must make NOW, to be asked INLINE. Empty in a healthy graph. */
3
4
  export function pendingEscalations(decisions) {
4
5
  const out = [];
@@ -12,6 +13,26 @@ export function pendingEscalations(decisions) {
12
13
  resolution: `supersede the others: re-record the chosen one with supersedes:<other-id>, or split the topic.`,
13
14
  });
14
15
  }
16
+ // Imported ADRs are immediately useful as advisory memory, but reading a file
17
+ // cannot mint human authority. Ask about exactly one at a time in ordinary
18
+ // session orientation; once answered, the next one naturally surfaces.
19
+ const imported = pendingImportedAdrReviews(decisions);
20
+ const next = imported[0];
21
+ if (next) {
22
+ const sourceHash = importedAdrSourceHash(next);
23
+ const reviewHash = importedAdrReviewHash(next);
24
+ const sourcePath = next.related_files[0] ?? "its ADR source";
25
+ const remaining = imported.length - 1;
26
+ const clip = (value, max) => value.length > max ? value.slice(0, max - 1).trimEnd() + "…" : value;
27
+ out.push({
28
+ kind: "imported-adr-review",
29
+ topic: next.topic ?? next.id,
30
+ decisionIds: [next.id],
31
+ question: `I imported ADR “${clip(next.title, 90)}” (${next.id}) as advisory memory. Approve it as human-confirmed project authority, or decline and keep it advisory?`,
32
+ detail: `${sourcePath} · source ${sourceHash} · review ${reviewHash} · ${clip(next.decision, 180)}${remaining ? ` · ${remaining} more imported ADR(s) will follow one at a time` : ""}`,
33
+ resolution: `after the human answers, call hunch_review_imported_adr with decision_id=${next.id}, expected_source_hash=${sourceHash}, expected_review_hash=${reviewHash}, and disposition=approve|decline; CLI: hunch review --approve-import|--decline-import ${next.id} --expected-source-hash ${sourceHash} --expected-review-hash ${reviewHash} --reviewed-by <you>`,
34
+ });
35
+ }
15
36
  return out;
16
37
  }
17
38
  /** The Constitution's genuine human moments (§59.5.3), framed as inline questions:
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Hash-bound human review for live ADRs imported from MADR/Nygard documents.
3
+ *
4
+ * Import gives Hunch useful advisory memory immediately, but importing a file is
5
+ * not a human countersign. This module owns the narrow trust-elevation boundary:
6
+ * an explicit approve/decline answer applies only to the exact source bytes the
7
+ * person saw. Re-importing unchanged bytes preserves the answer; changed bytes
8
+ * discard it and become reviewable again.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { isCredentialFreeText } from "./types.js";
12
+ const SOURCE_HASH = /^sha256:[a-f0-9]{64}$/;
13
+ const REVIEW_RECEIPT = /^adr-review:(approved|declined):(sha256:[a-f0-9]{64})$/;
14
+ const REVIEW_HASH_PREFIX = "adr-review-candidate:";
15
+ const REVIEWER_PREFIX = "adr-reviewer:";
16
+ const REVIEWED_AT_PREFIX = "adr-reviewed-at:";
17
+ function sourceTokens(source) {
18
+ return source.split("+").filter(Boolean);
19
+ }
20
+ function withoutHumanConfirmation(source) {
21
+ return sourceTokens(source).filter((token) => token !== "human_confirmed").join("+");
22
+ }
23
+ function withHumanConfirmation(source) {
24
+ const tokens = sourceTokens(source);
25
+ if (!tokens.includes("human_confirmed"))
26
+ tokens.push("human_confirmed");
27
+ return tokens.join("+");
28
+ }
29
+ export function isImportedAdrDecision(decision) {
30
+ return sourceTokens(decision.provenance.source).includes("imported:madr");
31
+ }
32
+ export function importedAdrSourceHash(decision) {
33
+ return decision.provenance.evidence.find((item) => SOURCE_HASH.test(item)) ?? null;
34
+ }
35
+ /** Hash the complete mapped meaning, not just the ADR source bytes. This prevents
36
+ * a later importer/parser change from carrying old authority onto new semantics
37
+ * even when the Markdown file itself did not change. */
38
+ export function importedAdrReviewHash(decision) {
39
+ const evidence = decision.provenance.evidence.filter((item) => !REVIEW_RECEIPT.test(item)
40
+ && !item.startsWith(REVIEW_HASH_PREFIX)
41
+ && !item.startsWith(REVIEWER_PREFIX)
42
+ && !item.startsWith(REVIEWED_AT_PREFIX));
43
+ const canonical = {
44
+ id: decision.id,
45
+ title: decision.title,
46
+ topic: decision.topic,
47
+ status: decision.status,
48
+ context: decision.context,
49
+ decision: decision.decision,
50
+ consequences: decision.consequences,
51
+ alternatives_rejected: decision.alternatives_rejected,
52
+ rejected_tripwires: decision.rejected_tripwires,
53
+ related_components: decision.related_components,
54
+ related_files: decision.related_files,
55
+ supersedes: decision.supersedes,
56
+ superseded_by: decision.superseded_by,
57
+ caused_by_bug: decision.caused_by_bug,
58
+ commit: decision.commit,
59
+ valid_from: decision.valid_from ?? null,
60
+ valid_to: decision.valid_to,
61
+ retired: decision.retired,
62
+ date: decision.date,
63
+ import_evidence: evidence,
64
+ };
65
+ return `sha256:${createHash("sha256").update(JSON.stringify(canonical)).digest("hex")}`;
66
+ }
67
+ function currentReviewReceipt(decision) {
68
+ const sourceHash = importedAdrSourceHash(decision);
69
+ if (!sourceHash)
70
+ return null;
71
+ const reviewHash = importedAdrReviewHash(decision);
72
+ const recordedReviewHash = decision.provenance.evidence.find((value) => value.startsWith(REVIEW_HASH_PREFIX))?.slice(REVIEW_HASH_PREFIX.length);
73
+ if (recordedReviewHash !== reviewHash)
74
+ return null;
75
+ for (const item of decision.provenance.evidence) {
76
+ const match = REVIEW_RECEIPT.exec(item);
77
+ if (!match || match[2] !== sourceHash)
78
+ continue;
79
+ return {
80
+ disposition: match[1] === "approved" ? "approve" : "decline",
81
+ sourceHash,
82
+ reviewHash,
83
+ reviewer: decision.provenance.evidence.find((value) => value.startsWith(REVIEWER_PREFIX))?.slice(REVIEWER_PREFIX.length) ?? null,
84
+ reviewedAt: decision.provenance.evidence.find((value) => value.startsWith(REVIEWED_AT_PREFIX))?.slice(REVIEWED_AT_PREFIX.length) ?? null,
85
+ };
86
+ }
87
+ return null;
88
+ }
89
+ export function importedAdrReview(decision) {
90
+ const receipt = currentReviewReceipt(decision);
91
+ if (receipt)
92
+ return receipt;
93
+ const sourceHash = importedAdrSourceHash(decision);
94
+ if (sourceHash && sourceTokens(decision.provenance.source).includes("human_confirmed")) {
95
+ // Backward-compatible with an imported record countersigned before receipts
96
+ // existed. Its exact source hash still prevents carrying it onto changed bytes.
97
+ return {
98
+ disposition: "approve",
99
+ sourceHash,
100
+ reviewHash: importedAdrReviewHash(decision),
101
+ reviewer: null,
102
+ reviewedAt: decision.provenance.last_verified ?? null,
103
+ };
104
+ }
105
+ return null;
106
+ }
107
+ export function isPendingImportedAdrReview(decision) {
108
+ return isImportedAdrDecision(decision)
109
+ && decision.status === "accepted"
110
+ && !decision.superseded_by
111
+ && !decision.valid_to
112
+ && !!importedAdrSourceHash(decision)
113
+ && importedAdrReview(decision) === null;
114
+ }
115
+ export function pendingImportedAdrReviews(decisions) {
116
+ return decisions
117
+ .filter(isPendingImportedAdrReview)
118
+ .sort((a, b) => (a.date || "").localeCompare(b.date || "") || a.id.localeCompare(b.id));
119
+ }
120
+ function validateReviewer(reviewer) {
121
+ const normalized = reviewer.trim();
122
+ if (!normalized || normalized.length > 128 || /[\r\n]/.test(normalized) || !isCredentialFreeText(normalized)) {
123
+ throw new Error("ADR reviewer must be a credential-free label of 1-128 characters");
124
+ }
125
+ return normalized;
126
+ }
127
+ /** Apply one explicit answer to one exact live imported ADR. Never changes the
128
+ * ADR lifecycle or content: decline means "reviewed, keep advisory", not delete. */
129
+ export function applyImportedAdrReview(decision, input) {
130
+ if (!isImportedAdrDecision(decision))
131
+ throw new Error(`${decision.id} is not an imported MADR/Nygard decision`);
132
+ if (decision.status !== "accepted" || decision.superseded_by || decision.valid_to) {
133
+ throw new Error(`${decision.id} is no longer a live accepted ADR; refresh the review question`);
134
+ }
135
+ if (!SOURCE_HASH.test(input.expectedSourceHash))
136
+ throw new Error("expected ADR source hash must be sha256:<64 lowercase hex characters>");
137
+ const actualHash = importedAdrSourceHash(decision);
138
+ if (!actualHash || actualHash !== input.expectedSourceHash) {
139
+ throw new Error(`ADR source changed after the question was shown (expected ${input.expectedSourceHash}, current ${actualHash ?? "missing"}); review the current ADR instead`);
140
+ }
141
+ if (!SOURCE_HASH.test(input.expectedReviewHash))
142
+ throw new Error("expected ADR review hash must be sha256:<64 lowercase hex characters>");
143
+ const actualReviewHash = importedAdrReviewHash(decision);
144
+ if (actualReviewHash !== input.expectedReviewHash) {
145
+ throw new Error(`imported ADR meaning changed after the question was shown (expected ${input.expectedReviewHash}, current ${actualReviewHash}); review the current ADR instead`);
146
+ }
147
+ const reviewer = validateReviewer(input.reviewer);
148
+ const reviewedAt = input.reviewedAt ?? new Date().toISOString();
149
+ if (reviewedAt.length > 64 || !Number.isFinite(Date.parse(reviewedAt)))
150
+ throw new Error("ADR review timestamp must be ISO-compatible");
151
+ const evidence = decision.provenance.evidence.filter((item) => !REVIEW_RECEIPT.test(item) && !item.startsWith(REVIEW_HASH_PREFIX) && !item.startsWith(REVIEWER_PREFIX) && !item.startsWith(REVIEWED_AT_PREFIX));
152
+ evidence.push(`adr-review:${input.disposition === "approve" ? "approved" : "declined"}:${actualHash}`, `${REVIEW_HASH_PREFIX}${actualReviewHash}`, `${REVIEWER_PREFIX}${reviewer}`, `${REVIEWED_AT_PREFIX}${reviewedAt}`);
153
+ return {
154
+ ...decision,
155
+ provenance: {
156
+ ...decision.provenance,
157
+ source: input.disposition === "approve"
158
+ ? withHumanConfirmation(decision.provenance.source)
159
+ : withoutHumanConfirmation(decision.provenance.source),
160
+ confidence: input.disposition === "approve" ? Math.max(decision.provenance.confidence, 0.95) : Math.min(decision.provenance.confidence, 0.75),
161
+ evidence,
162
+ last_verified: reviewedAt,
163
+ },
164
+ };
165
+ }
166
+ /** Carry a review across idempotent re-import only when the source bytes match.
167
+ * A changed hash deliberately returns the clean new import, reopening review. */
168
+ export function carryImportedAdrReview(previous, next) {
169
+ if (!previous || !isImportedAdrDecision(previous) || !isImportedAdrDecision(next))
170
+ return next;
171
+ const previousHash = importedAdrSourceHash(previous);
172
+ const nextHash = importedAdrSourceHash(next);
173
+ if (!previousHash || previousHash !== nextHash)
174
+ return next;
175
+ const review = importedAdrReview(previous);
176
+ if (!review || review.reviewHash !== importedAdrReviewHash(next))
177
+ return next;
178
+ const receiptEvidence = previous.provenance.evidence.filter((item) => REVIEW_RECEIPT.test(item) || item.startsWith(REVIEW_HASH_PREFIX) || item.startsWith(REVIEWER_PREFIX) || item.startsWith(REVIEWED_AT_PREFIX));
179
+ return {
180
+ ...next,
181
+ provenance: {
182
+ ...next.provenance,
183
+ source: review.disposition === "approve"
184
+ ? withHumanConfirmation(next.provenance.source)
185
+ : withoutHumanConfirmation(next.provenance.source),
186
+ confidence: review.disposition === "approve" ? Math.max(next.provenance.confidence, 0.95) : next.provenance.confidence,
187
+ evidence: [...next.provenance.evidence, ...receiptEvidence],
188
+ last_verified: previous.provenance.last_verified,
189
+ },
190
+ };
191
+ }
192
+ //# sourceMappingURL=importReview.js.map
@@ -25,6 +25,7 @@
25
25
  import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
26
26
  import { tmpdir } from "node:os";
27
27
  import { join } from "node:path";
28
+ import { compareCodeUnits } from "./canonicalOrder.js";
28
29
  export const EXECUTION_OBLIGATION_CATEGORIES = [
29
30
  "evidence",
30
31
  "behavior",
@@ -34,6 +35,21 @@ export const EXECUTION_OBLIGATION_CATEGORIES = [
34
35
  "other",
35
36
  ];
36
37
  export const CONTRACT_AXES = ["runtime", "static", "serialization", "compatibility"];
38
+ function ownerSourcePath(path) {
39
+ return /^[A-Za-z0-9._/-]+\.(?:tsx?|php)$/.test(path)
40
+ && !/(?:^|\/)(?:tests?|__tests__)(?:\/|$)|\.test\.(?:tsx?|php)$/.test(path);
41
+ }
42
+ /** Extract top-level declarations for the bounded correction diagnostic. PHP
43
+ * declarations deliberately require column-zero PSR-style layout so methods or
44
+ * nested conditional declarations cannot masquerade as repository owners. */
45
+ function ownerDeclarations(path, content) {
46
+ const declaration = path.endsWith(".php")
47
+ ? /^(?:(?:#\[[^\r\n]*\])\r?\n)*(?:(abstract|final|readonly)\s+)*(class|interface|trait|enum|function)\s+&?\s*([A-Za-z_][A-Za-z0-9_]*)/gm
48
+ : /(?:^|\n)\s*(?:export\s+)?(?:declare\s+)?(interface|class|function|const|type)\s+([$A-Za-z_][$\w]*)/g;
49
+ return [...content.matchAll(declaration)].map((match) => path.endsWith(".php")
50
+ ? { kind: match[2], symbol: match[3], index: match.index ?? 0 }
51
+ : { kind: match[1], symbol: match[2], index: match.index ?? 0 });
52
+ }
37
53
  export const emptyState = () => ({
38
54
  turn: 0,
39
55
  soulInjected: false,
@@ -408,17 +424,14 @@ export function rankContractAxisRiskOwners(closure, sourceValue) {
408
424
  const content = typeof item.content === "string" && item.content.length <= 1_000_000 ? item.content : null;
409
425
  if (!path || !content || seen.has(path) || path.startsWith("/") || path.split("/").includes(".."))
410
426
  continue;
411
- if (!/^[A-Za-z0-9._/-]+\.tsx?$/.test(path) || /(?:^|\/)(?:tests?|__tests__)(?:\/|$)|\.test\.tsx?$/.test(path))
427
+ if (!ownerSourcePath(path))
412
428
  continue;
413
429
  seen.add(path);
414
430
  sources.push({ path, content });
415
431
  }
416
432
  const ranked = [];
417
- const declaration = /(?:^|\n)\s*(?:export\s+)?(?:declare\s+)?(interface|class|function|const|type)\s+([$A-Za-z_][$\w]*)/g;
418
433
  for (const source of sources) {
419
- for (const match of source.content.matchAll(declaration)) {
420
- const kind = match[1];
421
- const symbol = match[2];
434
+ for (const { kind, symbol } of ownerDeclarations(source.path, source.content)) {
422
435
  const normalized = symbol.toLowerCase().replace(/[^a-z0-9]/g, "");
423
436
  for (const [anchor, evidenceWeight] of candidates) {
424
437
  if (!normalized.includes(anchor))
@@ -446,7 +459,7 @@ export function rankContractAxisRiskOwners(closure, sourceValue) {
446
459
  }
447
460
  }
448
461
  }
449
- ranked.sort((a, b) => b.score - a.score || a.owner.localeCompare(b.owner));
462
+ ranked.sort((a, b) => b.score - a.score || compareCodeUnits(a.owner, b.owner));
450
463
  const ownerScores = new Map();
451
464
  for (const item of ranked) {
452
465
  if (!ownerScores.has(item.owner))
@@ -534,19 +547,18 @@ export function rankIssueImplementationOwners(issueValue, sourceValue, candidate
534
547
  const content = typeof item.content === "string" && item.content.length <= 1_000_000 ? item.content : null;
535
548
  if (!path || !content || seen.has(path) || path.startsWith("/") || path.split("/").includes(".."))
536
549
  continue;
537
- if (!/^[A-Za-z0-9._/-]+\.tsx?$/.test(path) || /(?:^|\/)(?:tests?|__tests__)(?:\/|$)|\.test\.tsx?$/.test(path))
550
+ if (!ownerSourcePath(path))
538
551
  continue;
539
552
  seen.add(path);
540
553
  sources.push({ path, content });
541
554
  }
542
555
  const declarations = [];
543
- const declaration = /^(?:export\s+)?(?:declare\s+)?(?:interface|class|function|const|type)\s+([$A-Za-z_][$\w]*)/gm;
544
556
  for (const source of sources) {
545
- const matches = [...source.content.matchAll(declaration)];
557
+ const matches = ownerDeclarations(source.path, source.content);
546
558
  for (let index = 0; index < matches.length; index++) {
547
559
  const match = matches[index];
548
- const symbol = match[1];
549
- const start = match.index ?? 0;
560
+ const symbol = match.symbol;
561
+ const start = match.index;
550
562
  const end = matches[index + 1]?.index ?? source.content.length;
551
563
  const text = `${symbol} ${symbol} ${source.content.slice(start, end)}`.slice(0, 80_000);
552
564
  const tokens = implementationOwnerTokens(text);
@@ -598,7 +610,7 @@ export function rankIssueImplementationOwners(issueValue, sourceValue, candidate
598
610
  symbol_disclosed: symbolDisclosed,
599
611
  path_disclosed: pathDisclosed,
600
612
  };
601
- }).sort((a, b) => b.score - a.score || a.owner.localeCompare(b.owner));
613
+ }).sort((a, b) => b.score - a.score || compareCodeUnits(a.owner, b.owner));
602
614
  const limit = Number.isSafeInteger(candidateLimit) ? Math.max(1, Math.min(4_000, candidateLimit)) : 20;
603
615
  return { candidates: candidates.slice(0, limit) };
604
616
  }
@@ -254,7 +254,7 @@ export const EdgeSchema = z.object({
254
254
  ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["provenance"], message: "resource relationship fields must remain bounded" });
255
255
  }
256
256
  });
257
- export const SymbolKind = z.enum(["function", "method", "class", "interface", "type", "variable", "file"]);
257
+ export const SymbolKind = z.enum(["function", "method", "class", "interface", "trait", "enum", "type", "variable", "file"]);
258
258
  export const SymbolMetricsSchema = z.object({
259
259
  loc: z.number().default(0),
260
260
  churn_90d: z.number().default(0).describe("times changed in last 90 days"),