@davesheffer/hunch 1.10.1 → 1.10.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/dist/cli/index.js CHANGED
@@ -3259,26 +3259,29 @@ program
3259
3259
  console.log(" Strict mode fails closed because an omitted source file could hide a semantic violation.");
3260
3260
  }
3261
3261
  }
3262
- if (!files.length) {
3263
- console.log(markdown ? renderMarkdown(emptyReport) : "No changed files to check.");
3264
- if (teamFreshnessFailure)
3265
- fail(teamFreshnessFailure);
3266
- if (opts.strict && semanticIssues.length)
3267
- process.exitCode = 1;
3268
- store.close();
3269
- return;
3270
- }
3271
3262
  // DIRECT (scope match) + NEAR (blast radius) + REGRESSION (re-added retired
3272
3263
  // code) + REDUNDANT (adds a symbol already defined elsewhere — advisory) + the
3273
3264
  // hardened strict gate + causal `why` citations — all assembled by the shared
3274
3265
  // store.buildCheckReport (also used by the hunch_merge_verdict tool).
3275
- const diff = exactCommit ? commitDiff(exactCommit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
3276
- const report = store.buildCheckReport(files, diff, {
3277
- strict: !!opts.strict,
3278
- lastChange: (f) => lastChangeDate(f, root),
3279
- publicOnly: !!opts.publicOnly,
3280
- });
3281
- if (opts.blast && !markdown) {
3266
+ //
3267
+ // A ZERO-FILE diff must NOT short-circuit the run: the per-file report has nothing
3268
+ // to say, but the GRAPH-WIDE gates below (Architectural Conformance and Constitution
3269
+ // policy) ask "does the code, right now, still satisfy recorded intent?" — a question
3270
+ // independent of what this diff touched. Returning early here made a delete-only PR a
3271
+ // vacuous green (deletions are excluded by the enumerators' --diff-filter=ACMR, so
3272
+ // such a PR enumerates zero files) — including a deletion of the very symbol a
3273
+ // blocking conformance predicate or an active policy guards.
3274
+ const diff = files.length
3275
+ ? (exactCommit ? commitDiff(exactCommit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root))
3276
+ : "";
3277
+ const report = files.length
3278
+ ? store.buildCheckReport(files, diff, {
3279
+ strict: !!opts.strict,
3280
+ lastChange: (f) => lastChangeDate(f, root),
3281
+ publicOnly: !!opts.publicOnly,
3282
+ })
3283
+ : emptyReport;
3284
+ if (opts.blast && !markdown && files.length) {
3282
3285
  console.log(`Blast radius of ${files.length} changed file(s):`);
3283
3286
  for (const f of files) {
3284
3287
  const b = store.blastRadiusFiles(f);
@@ -3287,7 +3290,9 @@ program
3287
3290
  }
3288
3291
  console.log("");
3289
3292
  }
3290
- console.log(markdown ? renderMarkdown(report) : renderText(report));
3293
+ console.log(files.length
3294
+ ? (markdown ? renderMarkdown(report) : renderText(report))
3295
+ : (markdown ? renderMarkdown(emptyReport) : "No changed files to check."));
3291
3296
  // ARCHITECTURAL CONFORMANCE: does the RESULTING code still satisfy every recorded
3292
3297
  // architectural invariant? This is graph-reachability, not a diff — so it catches semantic
3293
3298
  // violations a pattern-matcher / SAST can't express (a controller that now reaches the DB
@@ -3741,19 +3746,29 @@ program
3741
3746
  // When the prompt reads like a correction ("no / that's wrong / never X"),
3742
3747
  // nudge the agent to PERSIST it as an enforced constraint (Never Twice) —
3743
3748
  // not just obey it this once and forget it next session.
3744
- let text = looksLikeCorrection(evt.prompt) ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
3749
+ const isCorrection = looksLikeCorrection(evt.prompt);
3750
+ let text = isCorrection ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
3751
+ // Payloads that must NEVER be deduped away. The dedup key hashes CONTENT, and
3752
+ // this content is assembled from constants — so two back-to-back corrections
3753
+ // produce byte-identical text and the second (usually the escalating one) was
3754
+ // silently swallowed, never becoming an enforced rule. Same for the unverified
3755
+ // nag, which is documented as the one nag that must repeat but rode the same
3756
+ // deduped payload and so fired once per streak.
3757
+ let mustDeliver = isCorrection;
3745
3758
  // Pipeline turn bookkeeping (fresh block budget) + the one nag that must
3746
3759
  // repeat: edits from an earlier turn still unverified.
3747
3760
  if (evt.session_id && pipelineEnabled()) {
3748
3761
  const st = onPrompt(loadPipelineState(evt.session_id));
3749
3762
  savePipelineState(evt.session_id, st);
3750
- if (!st.verifyAfterEdit)
3763
+ if (!st.verifyAfterEdit) {
3751
3764
  text += `\n\n${UNVERIFIED_NAG}`;
3765
+ mustDeliver = true;
3766
+ }
3752
3767
  }
3753
- // Once per session is enough for the availability reminder — repeating it
3754
- // every prompt burns context for zero information. A correction nudge has
3755
- // different content, so it always comes through (dec_244397d920).
3756
- if (injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
3768
+ // Once per session is enough for the bare availability reminder — repeating it
3769
+ // every prompt burns context for zero information (dec_244397d920). Only that
3770
+ // ambient case is deduped.
3771
+ if (!mustDeliver && injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
3757
3772
  return;
3758
3773
  emitContext(provider, "UserPromptSubmit", text);
3759
3774
  return;
@@ -4687,6 +4702,7 @@ program
4687
4702
  .option("--no-llm", "skip LLM prose; deterministic template pages only")
4688
4703
  .option("--prose-heal", "also LLM-rewrite each adopted copy's reconciled overview (subscription; the deterministic corrections always remain)")
4689
4704
  .option("--private", "render the FULL graph (private overlay included) and write the wiki into the OVERLAY repo — nothing lands in this repo")
4705
+ .option("--private-prose", "opt in to LLM prose for a PRIVATE-split overlay wiki — this SENDS overlay records (decision text, private constraints, private bug root causes) to the configured provider. Off by default.")
4690
4706
  .action(async (opts) => {
4691
4707
  const { store, root } = storeFor();
4692
4708
  try {
@@ -4752,7 +4768,21 @@ program
4752
4768
  return fail("--prose-heal needs the LLM — drop --no-llm.");
4753
4769
  let prose;
4754
4770
  let adoptionProse;
4755
- if (opts.llm !== false) {
4771
+ // A PRIVATE-SPLIT overlay's packs carry the full union (source: "all") — overlay
4772
+ // decision context/rationale/rejected alternatives, private constraint statements,
4773
+ // private bug root causes. Sending that to an external subscription CLI would
4774
+ // silently break the storage-private promise every other path here enforces
4775
+ // (public-only CI comments, public-only grounding, public-only wiki manifests), in
4776
+ // the very command documented as the way to build the private wiki. So prose is OFF
4777
+ // by default for that home and needs an explicit --private-prose. A SHARED overlay
4778
+ // is deliberately excluded: there the team already routes captures through the
4779
+ // configured provider by recorded policy.
4780
+ const privateSplit = home.kind === "private" && store.mode === "private";
4781
+ const proseBlockedForPrivacy = privateSplit && !opts.privateProse;
4782
+ if (opts.llm !== false && proseBlockedForPrivacy) {
4783
+ console.log("Private overlay: LLM prose is OFF (pages would send overlay records to the configured provider). Deterministic template pages; pass --private-prose to opt in.");
4784
+ }
4785
+ if (opts.llm !== false && !proseBlockedForPrivacy) {
4756
4786
  const provider = await selectProvider({ root });
4757
4787
  if (provider.draftProse) {
4758
4788
  console.log(`Prose via ${provider.name}; the drift-bearing skeleton stays deterministic.`);
@@ -1,3 +1,4 @@
1
+ import { externalImportNodeId } from "./externalImports.js";
1
2
  function resolveSymbols(graph, ref) {
2
3
  const syms = graph.symbols;
3
4
  if (ref.startsWith("sym_"))
@@ -48,17 +49,30 @@ function evalPredicate(graph, d, p) {
48
49
  return { ...base, satisfied: false, detail: `subject "${p.subject}" not found in the graph — intent's subject is gone` };
49
50
  const wantReach = p.assert === "calls" || p.assert === "imports";
50
51
  const objects = p.object ? resolveSymbols(graph, p.object) : [];
51
- if (!objects.length) {
52
- // a required target gone the link can't hold (violated); a forbidden one trivially holds.
52
+ // An external package is a VIRTUAL graph node (`ext_<hash>`, see core/externalImports.ts),
53
+ // never a Symbol record so resolveSymbols can NEVER find one. Without this, every
54
+ // `not-imports <symbol> <package>` predicate fell straight into the "target not found ⇒
55
+ // a forbidden relation trivially holds" branch below and reported ✅ forever, while the
56
+ // graph right beside it recorded the violating `imports` edge. Resolve the package from
57
+ // the edge set instead, which is where the indexer actually puts it.
58
+ const externalId = p.object ? externalImportNodeId(p.object) : null;
59
+ const objectIds = objects.length
60
+ ? objects.map((o) => o.id)
61
+ : externalId && graph.edges.some((e) => e.to === externalId)
62
+ ? [externalId]
63
+ : [];
64
+ if (!objectIds.length) {
65
+ // a required target gone ⇒ the link can't hold (violated); a forbidden one trivially
66
+ // holds — nothing can reach a node the graph does not contain.
53
67
  return { ...base, satisfied: !wantReach, detail: `target "${p.object ?? ""}" not found in the graph` };
54
68
  }
55
69
  // A required relation cannot guess which same-name symbol carries the intent.
56
70
  // Force qualification instead of accidentally proving a different binding.
57
- if (wantReach && (subjects.length !== 1 || objects.length !== 1)) {
71
+ if (wantReach && (subjects.length !== 1 || objectIds.length !== 1)) {
58
72
  return {
59
73
  ...base,
60
74
  satisfied: false,
61
- detail: `ambiguous required binding (${subjects.length} subject, ${objects.length} target matches) — qualify as file:symbol; intent VIOLATED`,
75
+ detail: `ambiguous required binding (${subjects.length} subject, ${objectIds.length} target matches) — qualify as file:symbol; intent VIOLATED`,
62
76
  };
63
77
  }
64
78
  // A forbidden relation is conservative in the other direction: ANY matching
@@ -66,7 +80,7 @@ function evalPredicate(graph, d, p) {
66
80
  // only the first target lets a duplicate symbol hide a violation.
67
81
  const linked = subjects.some((subject) => {
68
82
  const reached = reaches(graph, subject.id, p.transitive);
69
- return objects.some((object) => reached.has(object.id));
83
+ return objectIds.some((id) => reached.has(id));
70
84
  });
71
85
  const satisfied = wantReach ? linked : !linked;
72
86
  const via = p.transitive ? " (transitively)" : "";
@@ -25,9 +25,17 @@ export function matchForbids(f, addedDeps, scopedAdded) {
25
25
  // tripping an in-scope edit.
26
26
  const hitDeps = [];
27
27
  for (const dep of f.deps) {
28
- const added = [...addedDeps].find((d) => d === dep || d.startsWith(`${dep}/`));
29
- if (added && codeLines.some((l) => importsDep(l, added)))
30
- hitDeps.push(added);
28
+ // EVERY added specifier that satisfies the forbid, not just the first: one commit
29
+ // can add `lodash` in an out-of-scope file and `lodash/groupBy` in the scoped one.
30
+ // Taking only `.find`'s first match meant the out-of-scope spelling shadowed the
31
+ // real in-scope violation and the blocking rule failed OPEN — with the outcome
32
+ // depending on diff order, i.e. on an unrelated file's name.
33
+ for (const added of addedDeps) {
34
+ if (added !== dep && !added.startsWith(`${dep}/`))
35
+ continue;
36
+ if (codeLines.some((l) => importsDep(l, added)))
37
+ hitDeps.push(added);
38
+ }
31
39
  }
32
40
  if (hitDeps.length)
33
41
  return { tier: "dep", evidence: hitDeps.map((d) => `+import ${d}`) };
@@ -89,6 +89,16 @@ export function scanRepoDocs(decisions, root) {
89
89
  else if (superseded && current && current.id !== a.pin) {
90
90
  issues.push(`line ${a.line}: pinned to superseded ${a.pin}; current for "${a.topic}" is ${current.id}`);
91
91
  }
92
+ else if (pinned.status === "rejected") {
93
+ // The ledger tells readers to "Trust ✅". A doc pinned to an approach the team
94
+ // explicitly REJECTED is the opposite of grounded — and the pre-edit hook, which
95
+ // only injects live decisions, already disagrees with the ✅ this used to award.
96
+ issues.push(`line ${a.line}: pinned to ${a.pin}, a REJECTED decision (topic "${a.topic}")${current ? `; current is ${current.id}` : ""}`);
97
+ }
98
+ else if (pinned.status === "proposed") {
99
+ // Roadmap intent, not an in-force answer: it neither grounds the doc nor makes
100
+ // it stale, so the doc falls to the honest "unverified" tier.
101
+ }
92
102
  else if (!superseded) {
93
103
  groundedPins++;
94
104
  }
@@ -131,18 +131,26 @@ export function analyzeDiff(diff) {
131
131
  }
132
132
  // ---- inside a hunk: content lines ----
133
133
  if (raw.startsWith("+")) {
134
- if (!isCode(curFile))
135
- continue;
136
- addedLines++;
137
- if (!curAdded && !curDeleted)
138
- filesModified.add(curFile);
139
134
  const body = raw.slice(1);
135
+ // Raw added lines are captured for EVERY file, before the code-only gate:
136
+ // content-matched constraints and Veto tripwires are not code-only rules
137
+ // (a blocking invariant legitimately scopes .github/workflows/**, *.sql,
138
+ // Dockerfile). Skipping them here left `scopedAdded` empty, which
139
+ // buildCheckReport reads as "cannot prove a violation ⇒ complies" — so the
140
+ // pre-edit hook denied the edit while `hunch check --strict` passed the
141
+ // very commit that landed it. Symbol/import extraction and the churn
142
+ // counters below stay code-only, unchanged.
140
143
  let lines = addedLinesBy.get(curFile);
141
144
  if (!lines) {
142
145
  lines = [];
143
146
  addedLinesBy.set(curFile, lines);
144
147
  }
145
148
  lines.push(body);
149
+ if (!isCode(curFile))
150
+ continue;
151
+ addedLines++;
152
+ if (!curAdded && !curDeleted)
153
+ filesModified.add(curFile);
146
154
  const d = declOf(body);
147
155
  if (d)
148
156
  declsFor(curFile)?.added.set(d.name, d);
@@ -711,6 +711,22 @@ export function isGitCleanPath(root, rel) {
711
711
  return false;
712
712
  }
713
713
  }
714
+ /** The committed (HEAD) content of a tracked file, or null when the path is
715
+ * untracked/absent at HEAD or git is unavailable. Used to decide whether a
716
+ * dirty grounding doc differs from HEAD ONLY inside its generated section
717
+ * (the stranded-grounding heal, fnd_b269d5c422). */
718
+ export function headFileContent(root, rel) {
719
+ try {
720
+ return execFileSync("git", ["-C", root, "show", `HEAD:${rel.replace(/\\/g, "/")}`], {
721
+ encoding: "utf8",
722
+ env: foreignRepoEnv(process.env),
723
+ maxBuffer: 16 * 1024 * 1024,
724
+ });
725
+ }
726
+ catch {
727
+ return null;
728
+ }
729
+ }
714
730
  /** Is the staged set a clean, MEMORY-ONLY change — only JSON record adds/updates, nothing else?
715
731
  * The overlay store is entirely JSON (decisions/, bugs/, …, manifest.json). A real memory sync
716
732
  * is purely additive; a DELETION, rename, or any non-.json staged path means hunchDir is NOT a
@@ -6,19 +6,25 @@
6
6
  * four files.
7
7
  */
8
8
  import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
9
- const TS_QUERY = `
10
- (function_declaration name: (identifier) @fn.name) @fn.def
11
- (generator_function_declaration name: (identifier) @fn.name) @fn.def
12
- (method_definition name: (property_identifier) @method.name) @method.def
13
- (class_declaration name: (type_identifier) @class.name) @class.def
14
- (interface_declaration name: (type_identifier) @iface.name) @iface.def
15
- (type_alias_declaration name: (type_identifier) @type.name) @type.def
16
- (variable_declarator
17
- name: (identifier) @arrow.name
18
- value: [(arrow_function) (function_expression)]) @arrow.def
19
- (import_statement source: (string) @import.src)
20
- (call_expression function: (identifier) @call.id)
21
- (call_expression function: (member_expression property: (property_identifier) @call.member))
9
+ const TS_QUERY = `
10
+ (function_declaration name: (identifier) @fn.name) @fn.def
11
+ (generator_function_declaration name: (identifier) @fn.name) @fn.def
12
+ (method_definition name: (property_identifier) @method.name) @method.def
13
+ (class_declaration name: (type_identifier) @class.name) @class.def
14
+ (interface_declaration name: (type_identifier) @iface.name) @iface.def
15
+ (type_alias_declaration name: (type_identifier) @type.name) @type.def
16
+ (variable_declarator
17
+ name: (identifier) @arrow.name
18
+ value: [(arrow_function) (function_expression)]) @arrow.def
19
+ (import_statement source: (string) @import.src)
20
+ (call_expression function: (identifier) @call.id)
21
+ (call_expression function: (member_expression property: (property_identifier) @call.member))
22
+ ;; Construction IS a call. Without these, \`new Foo()\` produced no edge at all, so
23
+ ;; every class in a TS/JS repo had fan_in 0: blast radius before a constructor
24
+ ;; change came back empty, and a \`not-calls\` conformance predicate over a class
25
+ ;; could never see its own counterexample.
26
+ (new_expression constructor: (identifier) @call.id)
27
+ (new_expression constructor: (member_expression property: (property_identifier) @call.member))
22
28
  `;
23
29
  const TS_BUILTIN_METHODS = new Set([
24
30
  "map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
@@ -65,21 +71,28 @@ const TSX = {
65
71
  grammarKey: "tsx",
66
72
  loadGrammar: () => loadNativeTreeSitter().tsx,
67
73
  };
68
- const PY_QUERY = `
69
- (class_definition
70
- name: (identifier) @class.name
71
- body: (block
72
- [
73
- (function_definition name: (identifier) @method.name) @method.def
74
- (decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
75
- ])) @class.def
76
- (function_definition name: (identifier) @fn.name) @fn.def
77
- (import_statement name: (dotted_name) @import.src)
78
- (import_statement name: (aliased_import name: (dotted_name) @import.src))
79
- (import_from_statement module_name: (dotted_name) @import.src)
80
- (import_from_statement module_name: (relative_import) @import.src)
81
- (call function: (identifier) @call.id)
82
- (call function: (attribute attribute: (identifier) @call.member))
74
+ const PY_QUERY = `
75
+ (class_definition
76
+ name: (identifier) @class.name
77
+ body: (block
78
+ [
79
+ (function_definition name: (identifier) @method.name) @method.def
80
+ (decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
81
+ ])) @class.def
82
+ ;; Every class, including one with no directly-nested def: dataclasses, Exception
83
+ ;; subclasses, Enums, TypedDicts and pydantic models are method-less by design and
84
+ ;; were invisible to the entire graph (no symbol, no component, no edges), so
85
+ ;; \`hunch why\` and blast radius came back empty for exactly the classes a refactor
86
+ ;; breaks. parse.ts keys pendingDefs by node id and keeps the first classification,
87
+ ;; so a class that ALSO matches the method-bearing pattern above is not duplicated.
88
+ (class_definition name: (identifier) @class.name) @class.def
89
+ (function_definition name: (identifier) @fn.name) @fn.def
90
+ (import_statement name: (dotted_name) @import.src)
91
+ (import_statement name: (aliased_import name: (dotted_name) @import.src))
92
+ (import_from_statement module_name: (dotted_name) @import.src)
93
+ (import_from_statement module_name: (relative_import) @import.src)
94
+ (call function: (identifier) @call.id)
95
+ (call function: (attribute attribute: (identifier) @call.member))
83
96
  `;
84
97
  const PY_BUILTIN_METHODS = new Set([
85
98
  "get", "set", "keys", "values", "items", "pop", "popitem", "update", "setdefault", "copy", "clear",
@@ -10,6 +10,17 @@ import { wikiSummary } from "../wiki/wiki.js";
10
10
  import { PolicyRepository } from "../constitution/repository.js";
11
11
  const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
12
12
  const END = "<!-- HUNCH:END -->";
13
+ /** Remove the managed HUNCH section (markers inclusive), leaving only the
14
+ * user-authored surroundings. Lets a caller decide whether two versions of a
15
+ * doc differ ONLY in generated content (the stranded-grounding heal,
16
+ * fnd_b269d5c422): equal outside the block ⇒ regenerating cannot lose prose. */
17
+ export function stripManagedSection(text) {
18
+ const iStart = text.indexOf(START);
19
+ const iEnd = text.indexOf(END);
20
+ if (iStart < 0 || iEnd <= iStart)
21
+ return text;
22
+ return text.slice(0, iStart) + text.slice(iEnd + END.length);
23
+ }
13
24
  export function renderHunchSection(store, root) {
14
25
  const constraints = store.json
15
26
  .loadAll("constraints")
@@ -21,8 +21,8 @@ import { readFileSync, existsSync, mkdirSync } from "node:fs";
21
21
  import { writeFileAtomic } from "../core/io.js";
22
22
  import { homedir } from "node:os";
23
23
  import { join, dirname } from "node:path";
24
- import { renderHunchSection, upsertSection, updateClaudeMd } from "./claudemd.js";
25
- import { isGitCleanPath } from "../extractors/git.js";
24
+ import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd } from "./claudemd.js";
25
+ import { headFileContent, isGitCleanPath } from "../extractors/git.js";
26
26
  /** Strip // line and block comments + trailing commas (JSONC → JSON). String-aware
27
27
  * (double-quoted, with escapes) so a // inside a value isn't mangled. VS Code's
28
28
  * .vscode/mcp.json is JSONC, so we must tolerate comments. */
@@ -444,22 +444,50 @@ export function refreshExistingGrounding(root, store) {
444
444
  }
445
445
  return changed;
446
446
  }
447
- /** Capture-commit refresh: rewrite ONLY grounding docs that are git-clean, and return the
448
- * absolute paths of the ones that changed so the caller folds them into the memory commit
449
- * (commitAndPushHunch alsoStage). This keeps committed record counts permanently true
450
- * every capture used to bump the count and re-stale the committed docs, failing the
451
- * release gate's clean-tree check on the next CI index (the refresh-counts treadmill).
452
- * A user-dirty or untracked doc is left completely untouched (never refreshed, never
453
- * staged); it heals on the next manual `hunch sync` or `hunch index`. */
447
+ /** Wholly-Hunch-owned grounding docs (namespaced rule files the generators emit
448
+ * in full). Any dirt in these is generated dirt by contract there is no user
449
+ * prose to protect, so a stale copy is always safe to regenerate and stage. */
450
+ const WHOLLY_OWNED_GROUNDING = new Set([
451
+ join(".cursor", "rules", "hunch.mdc"),
452
+ join(".windsurf", "rules", "hunch.md"),
453
+ ]);
454
+ /** Is a DIRTY grounding doc's divergence from HEAD confined to generated content?
455
+ * Marker-managed docs (CLAUDE.md, AGENTS.md, copilot-instructions): compare
456
+ * worktree vs HEAD with the managed section stripped from both — equal outside
457
+ * the block means regenerating cannot lose user prose. Wholly-owned rule files
458
+ * need no comparison. Untracked files return false: a doc the user hasn't
459
+ * committed is theirs to stage. */
460
+ function generatedDirtOnly(root, rel, current) {
461
+ if (WHOLLY_OWNED_GROUNDING.has(rel))
462
+ return headFileContent(root, rel) !== null;
463
+ const head = headFileContent(root, rel);
464
+ if (head === null)
465
+ return false;
466
+ return stripManagedSection(head) === stripManagedSection(current);
467
+ }
468
+ /** Capture-commit refresh: rewrite grounding docs that are git-clean OR whose only
469
+ * divergence from HEAD is generated content, and return the absolute paths to fold
470
+ * into the memory commit (commitAndPushHunch alsoStage). This keeps committed record
471
+ * counts permanently true — every capture used to bump the count and re-stale the
472
+ * committed docs, failing the release gate's clean-tree check on the next CI index
473
+ * (the refresh-counts treadmill). The generated-dirt branch closes the second half
474
+ * (fnd_b269d5c422): once a doc went stale-dirty, the clean-only rule skipped it on
475
+ * every later flush FOREVER, and each release needed a manual chore commit. A doc
476
+ * whose USER PROSE differs from HEAD is still left completely untouched. */
454
477
  export function refreshCommittableGrounding(root, store) {
455
478
  const changed = [];
456
479
  for (const [rel, write] of groundingTargets(root, store)) {
457
480
  const file = join(root, rel);
458
- if (!existsSync(file) || !isGitCleanPath(root, rel))
481
+ if (!existsSync(file))
459
482
  continue;
460
483
  const before = readFileSync(file, "utf8");
484
+ const clean = isGitCleanPath(root, rel);
485
+ if (!clean && !generatedDirtOnly(root, rel, before))
486
+ continue;
461
487
  write();
462
- if (readFileSync(file, "utf8") !== before)
488
+ // A doc that was stale-DIRTY must be staged even when the regeneration is a
489
+ // byte no-op — the commit is what re-syncs HEAD with the worktree.
490
+ if (readFileSync(file, "utf8") !== before || !clean)
463
491
  changed.push(file);
464
492
  }
465
493
  return changed;
@@ -1527,6 +1527,7 @@ export function buildServerWithRootControl(initialRoot) {
1527
1527
  server,
1528
1528
  getRoot: () => root,
1529
1529
  setRoot,
1530
+ cancelPendingRoot: () => { pendingRoot = null; },
1530
1531
  };
1531
1532
  }
1532
1533
  /** Back-compatible server construction for tests and callers that do not need
@@ -1555,6 +1556,14 @@ export function wireClientRoots(control, fallback) {
1555
1556
  return;
1556
1557
  const next = resolveActiveRoot((response?.roots ?? []).map((root) => root.uri), fallback);
1557
1558
  if (!next) {
1559
+ // Cancel a swap parked by an EARLIER generation. setRoot defers when a tool
1560
+ // request is in flight; if the client's advertised set has since become
1561
+ // ambiguous, applying that stale swap once the request drains would re-home
1562
+ // to a repo the client no longer unambiguously advertises — writing (and
1563
+ // auto-committing) a capture into the wrong repository, which is precisely
1564
+ // what this refusal exists to prevent. Without this the message below was
1565
+ // also a lie: the root did NOT stay put.
1566
+ control.cancelPendingRoot();
1558
1567
  console.error("[hunch-mcp] multiple client roots are equally plausible; keeping the current Hunch root");
1559
1568
  return;
1560
1569
  }
package/dist/wiki/wiki.js CHANGED
@@ -28,7 +28,7 @@
28
28
  */
29
29
  import { createHash } from "node:crypto";
30
30
  import { existsSync, readFileSync, mkdirSync, rmSync } from "node:fs";
31
- import { join, dirname } from "node:path";
31
+ import { join, dirname, isAbsolute, relative, resolve, sep } from "node:path";
32
32
  import { writeFileAtomic } from "../core/io.js";
33
33
  import { compareCodeUnits } from "../core/canonicalOrder.js";
34
34
  import { hunchPaths, toPosixTarget } from "../core/paths.js";
@@ -36,15 +36,61 @@ import { isLive } from "../core/topics.js";
36
36
  import { scanRepoDocs } from "../core/docscan.js";
37
37
  import { adoptedSlug, adoptionHash, renderAdoptedDoc } from "./adopt.js";
38
38
  import { assembleGraphData, renderGraphPage } from "./graph.js";
39
+ /** A wiki directory that is safe to join against the pages root: relative, POSIX,
40
+ * no trailing slash, and free of any "." / ".." / empty segment. `.` would alias
41
+ * the pages root itself (clobbering tracked files like README.md) and `..` writes
42
+ * the rendered graph — which for the private home is the FULL overlay union —
43
+ * outside the repository entirely. Returns undefined for anything unsafe. */
44
+ function validDir(d) {
45
+ if (!d)
46
+ return undefined;
47
+ const v = toPosixTarget(d).replace(/\/+$/, "");
48
+ if (!v || isAbsolute(v) || /^[a-zA-Z]:/.test(v))
49
+ return undefined;
50
+ if (v.split("/").some((seg) => seg === "" || seg === "." || seg === ".."))
51
+ return undefined;
52
+ return v;
53
+ }
39
54
  /** Normalize a --dir override: POSIX separators, no trailing slash — the dir is
40
- * a committed manifest key prefix, so it must hash identically on every OS. */
41
- const normDir = (d) => d ? toPosixTarget(d).replace(/\/+$/, "") || undefined : undefined;
55
+ * a committed manifest key prefix, so it must hash identically on every OS.
56
+ * An unsafe override THROWS rather than silently falling back, so a rejected flag
57
+ * can never quietly write somewhere else. */
58
+ const normDir = (d) => {
59
+ if (d === undefined)
60
+ return undefined;
61
+ const v = validDir(d);
62
+ if (!v) {
63
+ throw new Error(`refusing --dir ${JSON.stringify(d)}: the wiki directory must be a relative path inside the repository (no "." or ".." segments, not absolute).`);
64
+ }
65
+ return v;
66
+ };
67
+ /** Resolve a manifest page key to an absolute path INSIDE this home's page
68
+ * directory, or null when it escapes.
69
+ *
70
+ * Both inputs that reach the join are untrusted. `.hunch/wiki-manifest.json` is a
71
+ * COMMITTED file (so CI can gate on it), which makes every page KEY inside it
72
+ * PR- and merge-influenceable — and `wikiStatus` classifies any key no current
73
+ * artifact claims as an orphan to be deleted. Without this check a key like
74
+ * "../../id_rsa" turned a plain `hunch wiki` into arbitrary file deletion, with
75
+ * `hunch drift` politely instructing the maintainer to run it. Re-checked at BOTH
76
+ * the write and the delete site, so a manifest written before this guard existed
77
+ * still cannot escape. */
78
+ function containedPagePath(home, page) {
79
+ if (!page || page.includes("\0") || isAbsolute(page) || /^[a-zA-Z]:/.test(page))
80
+ return null;
81
+ const baseDir = resolve(join(home.pagesRoot, home.dir));
82
+ const abs = resolve(join(home.pagesRoot, ...page.split("/")));
83
+ const rel = relative(baseDir, abs);
84
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
85
+ return null;
86
+ return abs;
87
+ }
42
88
  export function publicHome(root, dirOverride) {
43
89
  const manifestPath = join(hunchPaths(root).hunch, "wiki-manifest.json");
44
90
  return {
45
91
  kind: "public",
46
92
  pagesRoot: root,
47
- dir: normDir(dirOverride) ?? readWikiManifestAt(manifestPath)?.dir ?? "wiki",
93
+ dir: normDir(dirOverride) ?? validDir(readWikiManifestAt(manifestPath)?.dir) ?? "wiki",
48
94
  manifestPath,
49
95
  source: "public",
50
96
  };
@@ -60,7 +106,7 @@ export function privateHome(store, dirOverride) {
60
106
  return {
61
107
  kind: "private",
62
108
  pagesRoot: dirname(store.privateDir),
63
- dir: normDir(dirOverride) ?? readWikiManifestAt(manifestPath)?.dir ?? "wiki",
109
+ dir: normDir(dirOverride) ?? validDir(readWikiManifestAt(manifestPath)?.dir) ?? "wiki",
64
110
  manifestPath,
65
111
  source: "all",
66
112
  };
@@ -515,6 +561,11 @@ export function wikiStatus(store, home, srcRoot) {
515
561
  for (const [page, p] of Object.entries(manifest?.pages ?? {})) {
516
562
  if (expected.has(page))
517
563
  continue;
564
+ // The manifest is committed, so a key is attacker-influenceable through an
565
+ // ordinary PR — and reaching this list means "delete this file". Only a key
566
+ // that resolves inside this home's page directory may be classified at all.
567
+ if (!containedPagePath(home, page))
568
+ continue;
518
569
  (p.component.startsWith(ADOPTED_PREFIX) ? adoptionOrphans : orphans).push(page);
519
570
  }
520
571
  return { home, entries, docs, adoptions, adoptionOrphans, decisions, specs, index, now, graph, repoWide, orphans };
@@ -556,7 +607,10 @@ export async function generateWiki(store, srcRoot, home, opts) {
556
607
  /** Written-bytes ledger — the hand-edit tripwire recorded per page. */
557
608
  const bytesByPage = new Map();
558
609
  const put = (page, content) => {
559
- writeFileAtomic(join(home.pagesRoot, ...page.split("/")), content);
610
+ const abs = containedPagePath(home, page);
611
+ if (!abs)
612
+ throw new Error(`refusing to write wiki page ${JSON.stringify(page)}: it resolves outside ${join(home.pagesRoot, home.dir)}`);
613
+ writeFileAtomic(abs, content);
560
614
  bytesByPage.set(page, sha16(content));
561
615
  written.push(page);
562
616
  };
@@ -614,8 +668,16 @@ export async function generateWiki(store, srcRoot, home, opts) {
614
668
  const removed = [];
615
669
  for (const [pages, why] of [[status.orphans, "component gone"], [status.adoptionOrphans, "original healed or removed — copy retired"]]) {
616
670
  for (const page of pages) {
671
+ // Re-assert containment at the DELETE site too: wikiStatus already filters
672
+ // escaping manifest keys, but this is the primitive that removes files, so it
673
+ // must not depend on an upstream filter having run.
674
+ const abs = containedPagePath(home, page);
675
+ if (!abs) {
676
+ log(` ⚠ ${page} — refusing to remove a manifest entry that resolves outside ${join(home.pagesRoot, home.dir)}`);
677
+ continue;
678
+ }
617
679
  try {
618
- rmSync(join(home.pagesRoot, ...page.split("/")), { force: true });
680
+ rmSync(abs, { force: true });
619
681
  }
620
682
  catch {
621
683
  /* best effort */
@@ -627,8 +689,26 @@ export async function generateWiki(store, srcRoot, home, opts) {
627
689
  if (written.length || removed.length) {
628
690
  const pages = {};
629
691
  const entry = (page, component, hash, state) => {
630
- const keep = state === "fresh" ? prior?.pages[page] : undefined;
631
- pages[page] = keep ?? { component, hash, generated: opts.now, bytes: bytesByPage.get(page) };
692
+ const prev = prior?.pages[page];
693
+ const justWritten = bytesByPage.get(page);
694
+ // Carry the prior entry forward ONLY for a fresh page this run did not rewrite.
695
+ // A full regen (`only: "all"`) rewrites every page, so reusing the prior entry
696
+ // there recorded the OLD bytes against freshly written content — making the
697
+ // hand-edit tripwire fire on Hunch's own output, so `hunch wiki --check` exited
698
+ // 1 immediately after a successful `hunch wiki` and the tripwire's signal for a
699
+ // REAL hand edit was destroyed.
700
+ if (state === "fresh" && justWritten === undefined && prev) {
701
+ pages[page] = prev;
702
+ return;
703
+ }
704
+ pages[page] = {
705
+ component,
706
+ hash,
707
+ // Keep the original stamp for an unchanged-but-rewritten page so a full regen
708
+ // stays byte-idempotent in git.
709
+ generated: state === "fresh" && prev ? prev.generated : opts.now,
710
+ bytes: justWritten ?? prev?.bytes,
711
+ };
632
712
  };
633
713
  for (const e of status.entries)
634
714
  entry(e.page, e.pack.component.id, e.hash, e.state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.10.1",
3
+ "version": "1.10.2",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
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.",