@davesheffer/hunch 1.39.3 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/cli/index.js +59 -16
  2. package/dist/constitution/g2BehaviorCandidates.js +6 -1
  3. package/dist/constitution/g2Candidates.js +11 -1
  4. package/dist/constitution/structural.js +10 -0
  5. package/dist/core/delivery.d.ts +34 -0
  6. package/dist/core/delivery.js +60 -1
  7. package/dist/core/docanchors.js +181 -11
  8. package/dist/core/format.js +6 -1
  9. package/dist/core/glob.d.ts +12 -6
  10. package/dist/core/glob.js +12 -6
  11. package/dist/core/hookcache.d.ts +9 -2
  12. package/dist/core/hookcache.js +10 -3
  13. package/dist/core/paths.d.ts +21 -0
  14. package/dist/core/paths.js +39 -1
  15. package/dist/core/taskDelivery.js +27 -1
  16. package/dist/core/taskReportHook.d.ts +19 -0
  17. package/dist/core/taskReportHook.js +40 -4
  18. package/dist/core/verifyLauncher.d.ts +21 -0
  19. package/dist/core/verifyLauncher.js +37 -0
  20. package/dist/extractors/indexer.js +39 -9
  21. package/dist/extractors/k8sManifest.d.ts +13 -0
  22. package/dist/extractors/k8sManifest.js +103 -7
  23. package/dist/extractors/landscapeDiscovery.js +9 -1
  24. package/dist/extractors/nativeTreeSitter.d.ts +24 -0
  25. package/dist/extractors/nativeTreeSitter.js +54 -1
  26. package/dist/extractors/parse.js +6 -1
  27. package/dist/integrations/claudemd.js +3 -3
  28. package/dist/integrations/hooks.d.ts +43 -4
  29. package/dist/integrations/hooks.js +309 -22
  30. package/dist/mcp/server.js +19 -11
  31. package/dist/mcp/taskReportTools.d.ts +5 -9
  32. package/dist/mcp/taskReportTools.js +9 -20
  33. package/dist/store/changeLedger.d.ts +9 -3
  34. package/dist/store/changeLedger.js +36 -10
  35. package/dist/store/hunchStore.d.ts +42 -2
  36. package/dist/store/hunchStore.js +74 -16
  37. package/package.json +1 -1
  38. package/server.json +2 -2
package/dist/cli/index.js CHANGED
@@ -37,6 +37,9 @@ import { HunchStore } from "../store/hunchStore.js";
37
37
  import { JsonStore } from "../store/jsonStore.js";
38
38
  import { selectEmbedder } from "../store/embedder.js";
39
39
  import { assertCompleteRepoScan, indexRepo, mergeScannedEdges, scanRepo } from "../extractors/indexer.js";
40
+ // Importing this module is free (the addon load is a function call, not a
41
+ // top-level side effect); only `doctor` below actually calls the loader.
42
+ import { isParserLoadError, loadNativeTreeSitter } from "../extractors/nativeTreeSitter.js";
40
43
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
41
44
  import { parseTestReport } from "../extractors/testreport.js";
42
45
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
@@ -52,7 +55,7 @@ import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
52
55
  import { extractInlineIntent } from "../extractors/comments.js";
53
56
  import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
54
57
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
55
- import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, hookInvocationLines, formatHookInstall } from "../integrations/hooks.js";
58
+ import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, hookInvocationLines, formatHookInstall, sharedHooksNote } from "../integrations/hooks.js";
56
59
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
57
60
  import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
58
61
  import { installMergeDriver } from "../integrations/mergeDriver.js";
@@ -72,7 +75,7 @@ import { rankingStatusLine, resolveTaskRankingMode } from "../core/taskRankingMo
72
75
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
73
76
  import { compileVerifiedEvidenceMap, formatVerifiedEvidenceMap } from "../core/evidenceMap.js";
74
77
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
75
- import { buildDeliveryEnvelope, DELIVERY_PROFILES } from "../core/delivery.js";
78
+ import { buildDeliveryEnvelope, deliveryDedupeInput, DELIVERY_PROFILES } from "../core/delivery.js";
76
79
  import { deriveChangeIdentity } from "../core/changeIdentity.js";
77
80
  import { deriveChangeProof } from "../core/changeProof.js";
78
81
  import { discoverProjectDna, evaluateProjectDnaMatch } from "../core/projectDna.js";
@@ -395,15 +398,21 @@ program
395
398
  console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
396
399
  }
397
400
  }
401
+ // Collected so the linked-worktree note below can say what actually happened
402
+ // to the SHARED hooks dir (issue #316) rather than assuming.
403
+ const installs = [];
398
404
  if (isGitRepo(root)) {
399
405
  const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
400
406
  const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit, localOnly: syncToOverlay });
407
+ installs.push(h);
401
408
  for (const line of formatHookInstall(root, "post-commit hook", h, ` (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`))
402
409
  console.log(line);
403
410
  const pm = installPostMergeHook(root, inv.shell);
411
+ installs.push(pm);
404
412
  for (const line of formatHookInstall(root, "post-merge hook", pm, " (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)"))
405
413
  console.log(line);
406
414
  const pc = installPostCheckoutHook(root, inv.shell);
415
+ installs.push(pc);
407
416
  for (const line of formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: records this machine's branches + worktrees on checkout)"))
408
417
  console.log(line);
409
418
  const m = installMergeDriver(root, inv.shell);
@@ -414,6 +423,7 @@ program
414
423
  if (opts.enforce !== false || opts.enforceStrict) {
415
424
  const strict = !!opts.enforceStrict;
416
425
  const p = installPreCommitHook(root, inv.shell, strict);
426
+ installs.push(p);
417
427
  for (const line of formatHookInstall(root, "pre-commit constraint guard", p, ` (${strict ? "strict — fails only on direct, high-confidence, non-stale blocking invariants" : "advisory — flags invariants in scope or blast radius"})`))
418
428
  console.log(line);
419
429
  }
@@ -469,9 +479,8 @@ program
469
479
  if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private")) {
470
480
  console.log(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
471
481
  }
472
- if (isLinkedWorktree(root)) {
473
- console.log(` ✓ linked worktree — sharing the repo's hooks + memory (no separate setup needed)`);
474
- }
482
+ for (const line of sharedHooksNote(root, installs))
483
+ console.log(line);
475
484
  store.close();
476
485
  console.log("\n" + formatIntegrationHealth(inspectIntegrations(root)));
477
486
  console.log("\nTask reporting is configured through Hunch's agent instructions; actual agent activity has not been verified by setup. Reconnect the agent, then work normally. Completed task reports are available with `hunch report`.");
@@ -1436,6 +1445,20 @@ program
1436
1445
  indexNote = `\n ✓ code graph present in the checkout — blast-radius ready`;
1437
1446
  }
1438
1447
  }
1448
+ catch (error) {
1449
+ // The code graph is this command's CONVENIENCE; the worktree + branch
1450
+ // are its promise, and git has already created them. A dead native
1451
+ // parser (loaded on first parse, so it surfaces here rather than at
1452
+ // import) used to abort between the two: no success line, the ledger
1453
+ // step skipped, and a re-run then hitting "path already exists" with no
1454
+ // way forward. Downgrade it to the same note channel the other optional
1455
+ // steps use — the worktree stands, the user is told the index was
1456
+ // skipped and why, and `hunch index` there fixes it once TMPDIR/the
1457
+ // install is sound. Anything else is still a real failure of this step.
1458
+ if (!isParserLoadError(error))
1459
+ throw error;
1460
+ indexNote = `\n · code graph skipped — ${error.message}\n (the worktree is ready; run \`hunch index\` there once the parser loads)`;
1461
+ }
1439
1462
  finally {
1440
1463
  wstore.close();
1441
1464
  openStore = null;
@@ -5202,22 +5225,23 @@ program
5202
5225
  docGround;
5203
5226
  if (!hasContent)
5204
5227
  return; // no noise on files Hunch hasn't learned yet
5228
+ const supplements = [
5229
+ ...(retired.length ? [{
5230
+ id: "retired-code",
5231
+ kind: "retired-code",
5232
+ priority: 200,
5233
+ text: `⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ")}.`,
5234
+ }] : []),
5235
+ ...(docGround ? [{ id: "doc-grounding", kind: "doc-grounding", priority: 100, text: docGround }] : []),
5236
+ ...recentTasks,
5237
+ ];
5205
5238
  const envelope = buildDeliveryEnvelope(ctx, {
5206
5239
  profile: "builder",
5207
5240
  root,
5208
5241
  symbols: store.recs("symbols"),
5209
5242
  components: store.recs("components"),
5210
5243
  decisionCorpus: store.recs("decisions"),
5211
- supplements: [
5212
- ...(retired.length ? [{
5213
- id: "retired-code",
5214
- kind: "retired-code",
5215
- priority: 200,
5216
- text: `⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ")}.`,
5217
- }] : []),
5218
- ...(docGround ? [{ id: "doc-grounding", kind: "doc-grounding", priority: 100, text: docGround }] : []),
5219
- ...recentTasks,
5220
- ],
5244
+ supplements,
5221
5245
  });
5222
5246
  const text = envelope.text.trim();
5223
5247
  // Identical grounding already shown this session → one-line delta instead of
@@ -5254,7 +5278,12 @@ program
5254
5278
  // it never saw that grounding, so its dedup is scoped by its own agent
5255
5279
  // identity (hashed — the raw agent_id is never retained in the key).
5256
5280
  const agentKey = evt.agent_id ? `:${reportHash(evt.agent_id).slice(7, 19)}` : "";
5257
- if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}${agentKey}`, text) === "delta") {
5281
+ // Dedup on the envelope's stable IDENTITY projection, never on the
5282
+ // rendered block: serving the full text writes delivery receipts, and the
5283
+ // next call's task ranking reads them back and moves the wording ("today"
5284
+ // → "delivered today"), so hashing the presentation made this grounding
5285
+ // self-invalidating and re-sent the full block for unchanged records.
5286
+ if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}${agentKey}`, text, deliveryDedupeInput(envelope, supplements)) === "delta") {
5258
5287
  receipts("refreshed");
5259
5288
  emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${envelope.delivered.filter((item) => item.kind === "decisions").length} decision(s), ${envelope.delivered.filter((item) => item.kind === "constraints").length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
5260
5289
  return;
@@ -7191,6 +7220,20 @@ program
7191
7220
  const ctxWarning = await maybeWarnOllamaContext(provider.name, process.env);
7192
7221
  if (ctxWarning)
7193
7222
  console.log(ctxWarning);
7223
+ // The native addons load on first PARSE, not at import, so no other command
7224
+ // that merely starts up proves the parser works. Doctor is the one place
7225
+ // that should pay the ~1.5s load: without this line a broken parser (an
7226
+ // unwritable TMPDIR, a missing or wrong-arch prebuild, an addon preloaded
7227
+ // past the isolation guard) is invisible until an index run refuses.
7228
+ try {
7229
+ loadNativeTreeSitter();
7230
+ console.log(`parser: native tree-sitter addons load`);
7231
+ }
7232
+ catch (e) {
7233
+ console.log(`parser: ⛔ ${e.message}`);
7234
+ console.log(dim(` no file can be parsed — \`hunch index\` refuses rather than emptying the graph`));
7235
+ process.exitCode = 1;
7236
+ }
7194
7237
  const c = store.reindex().counts;
7195
7238
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
7196
7239
  try {
@@ -25,7 +25,11 @@ const DIRECT_TEST_DELTA_LIMITATIONS = [
25
25
  "Direct decision candidates cover newly named node:test test()/it() cases and existing literal-named cases whose body contains an added fixing-commit line.",
26
26
  ...LIMITATIONS.slice(1),
27
27
  ];
28
- const { Parser, typescript: tsLanguage, tsx: tsxLanguage } = loadNativeTreeSitter();
28
+ /** Resolved on first parse, not at import: loading the native addons copies six
29
+ * `.node` files into a temp dir and dlopens them (~1.5s+ cold), which every CLI
30
+ * command and every editor hook would otherwise pay just for importing this
31
+ * module. loadNativeTreeSitter() memoizes the runtime itself. */
32
+ const treeSitter = () => loadNativeTreeSitter();
29
33
  function safeTestFile(file) {
30
34
  return !!file
31
35
  && !isAbsolute(file)
@@ -175,6 +179,7 @@ function literalTestName(raw) {
175
179
  return name.trim() ? name : null;
176
180
  }
177
181
  function literalNodeTestCases(file, source) {
182
+ const { Parser, typescript: tsLanguage, tsx: tsxLanguage } = treeSitter();
178
183
  const language = /\.[cm]?[jt]sx$/.test(file) && /x$/.test(file) ? tsxLanguage : tsLanguage;
179
184
  const parser = new Parser();
180
185
  parser.setLanguage(language);
@@ -5,6 +5,7 @@ import { shortHash } from "../core/ids.js";
5
5
  import { hunchPathsForDir } from "../core/paths.js";
6
6
  import { commitMeta, fixCommits } from "../extractors/git.js";
7
7
  import { indexRepo } from "../extractors/indexer.js";
8
+ import { isParserLoadError } from "../extractors/nativeTreeSitter.js";
8
9
  import { HunchStore } from "../store/hunchStore.js";
9
10
  import { canonicalHash } from "./canonical.js";
10
11
  import { extractStructuralDelta } from "./delta.js";
@@ -62,10 +63,15 @@ function privateGrounding(decisionStore, graphStore, root) {
62
63
  });
63
64
  byCommit.set(meta.sha, list);
64
65
  }
65
- catch {
66
+ catch (error) {
66
67
  // A human decision can be real while still lacking a supported structural
67
68
  // binding. It contributes no candidate attestation rather than being
68
69
  // stretched over a coincidental fact from the same commit.
70
+ // A dead native parser is not that: it makes EVERY decision look
71
+ // unbindable, so the review packet would silently downgrade exact human
72
+ // grounding to unattested coincidence. Fail instead of under-attesting.
73
+ if (isParserLoadError(error))
74
+ throw error;
69
75
  }
70
76
  }
71
77
  return byCommit;
@@ -142,6 +148,10 @@ function buildFromIndexedGraph(decisionStore, graphStore, root, opts, resolution
142
148
  }
143
149
  }
144
150
  catch (error) {
151
+ // A dead native parser would turn every commit into a per-commit failure
152
+ // entry instead of an honest abort, so it is never recorded as one.
153
+ if (isParserLoadError(error))
154
+ throw error;
145
155
  failures.push({ commit, error: error.message });
146
156
  }
147
157
  }
@@ -5,6 +5,7 @@ import { pathMatchesGlob } from "../core/glob.js";
5
5
  import { shortHash } from "../core/ids.js";
6
6
  import { resolveRelativeImport } from "../core/relativeImports.js";
7
7
  import { commitMeta } from "../extractors/git.js";
8
+ import { isParserLoadError } from "../extractors/nativeTreeSitter.js";
8
9
  import { canonicalHash } from "./canonical.js";
9
10
  import { clampCandidateLimit, durationCutoff } from "./bootstrap.js";
10
11
  import { compileStructuralPolicy } from "./compiler.js";
@@ -645,6 +646,15 @@ export function bootstrapStructuralPolicies(store, root, repository, opts = {})
645
646
  report.compiled.push({ evidence: event, policy });
646
647
  }
647
648
  catch (e) {
649
+ // "uncompilable" is a verdict ABOUT the decision, and it is written to
650
+ // evidence. A dead native parser (extractStructuralDelta parses the
651
+ // commit's sources) says nothing about the decision — recording it would
652
+ // stamp every eligible decision uncompilable with a TMPDIR error as the
653
+ // stated reason, and a later run with a working parser would only
654
+ // reclassify what `canReclassify` still allows. Fail the bootstrap
655
+ // instead, the way the import-time load did.
656
+ if (isParserLoadError(e))
657
+ throw e;
648
658
  report.uncompilable++;
649
659
  const reason = e.message;
650
660
  if (canReclassify(prior) && (prior?.compiler?.status !== "uncompilable" || prior.compiler.reason !== reason)) {
@@ -28,6 +28,14 @@ export interface DeliverySupplement {
28
28
  text: string;
29
29
  /** Higher values are attempted first after ranked memory. */
30
30
  priority?: number;
31
+ /** Stable IDENTITY of the records behind `text`, for callers that dedup
32
+ * injections by hash (the pre-edit hook). Some supplements are
33
+ * self-invalidating: serving them writes delivery receipts, and the next
34
+ * render's wording, scores or slot order move with no record change. Set this
35
+ * to what is a property of the record — never of delivery state or the clock —
36
+ * and `deliveryDedupeInput` swaps it in for `text` when hashing. Omitted →
37
+ * `text` is its own identity. Never shown to the agent. */
38
+ hash_text?: string;
31
39
  }
32
40
  export interface DeliveredSupplement {
33
41
  id: string;
@@ -106,5 +114,31 @@ export interface DeliveryOptions {
106
114
  }
107
115
  /** Validate the public receipt without trusting a caller-supplied identity. */
108
116
  export declare function assertDeliveryEnvelope(envelope: DeliveryEnvelope): void;
117
+ /** The string a hash-based injection dedup (`injectionMode`'s `hashInput`) must
118
+ * key on instead of the rendered block: a STABLE IDENTITY PROJECTION of this
119
+ * envelope.
120
+ *
121
+ * THE RULE, for every caller and every provider: nothing that depends on
122
+ * delivery receipts, ranking warmth or scores, slot labels, line ORDER, or
123
+ * `now` may enter the dedupe hash; a record entering, leaving, or changing
124
+ * content MUST. Grounding that violates it is self-invalidating — serving the
125
+ * block writes delivery receipts, the next call's ranking reads them back, the
126
+ * wording moves ("today" → "delivered today"), the hash changes, and the full
127
+ * block is re-sent for records the agent already has.
128
+ *
129
+ * The projection, done line-wise over the one text the assembler produced:
130
+ * - a supplement that declared `hash_text` contributes that identity (kind +
131
+ * record id + the record's own content hash) instead of its rendered line —
132
+ * that is where reason text, slot labels and age words live;
133
+ * - every other line contributes verbatim, because it is already a property of
134
+ * the records (id, statement, scope, provenance tier, validation state);
135
+ * - each contiguous run of `- ` list lines is SORTED, so a pure re-ordering of
136
+ * the same records is not a change while a record entering or leaving the run
137
+ * still is.
138
+ *
139
+ * Nothing is removed from what the agent SEES — this string is never emitted.
140
+ * Never throws: on any surprise the caller gets `envelope.text` back, i.e. the
141
+ * original hash-the-presentation behaviour (a full block, never a wrong delta). */
142
+ export declare function deliveryDedupeInput(envelope: DeliveryEnvelope, supplements?: readonly DeliverySupplement[]): string;
109
143
  /** Build the one envelope used by CLI, MCP, and the edit hook. */
110
144
  export declare function buildDeliveryEnvelope(ctx: AssembledContext, options?: DeliveryOptions): DeliveryEnvelope;
@@ -21,6 +21,9 @@ const MIN_ADVISORY_CONFIDENCE = 0.5;
21
21
  const MIN_UNCONDITIONED_CONFIDENCE = 0.7;
22
22
  const MAX_ACTIONABLE_HYPOTHESES = 2;
23
23
  const MAX_PROFILE_HEADLINES = 8;
24
+ /** How far a supplement's text is clipped in the rendered line. Shared so
25
+ * `deliveryDedupeInput` can reconstruct that exact line to project over it. */
26
+ const SUPPLEMENT_HEADLINE_CHARS = 700;
24
27
  const PROFILE_BASE_SCORE = {
25
28
  builder: {
26
29
  constraints: 900,
@@ -457,6 +460,62 @@ export function assertDeliveryEnvelope(envelope) {
457
460
  throw new Error("delivery envelope receipt does not match its content");
458
461
  }
459
462
  }
463
+ /** The string a hash-based injection dedup (`injectionMode`'s `hashInput`) must
464
+ * key on instead of the rendered block: a STABLE IDENTITY PROJECTION of this
465
+ * envelope.
466
+ *
467
+ * THE RULE, for every caller and every provider: nothing that depends on
468
+ * delivery receipts, ranking warmth or scores, slot labels, line ORDER, or
469
+ * `now` may enter the dedupe hash; a record entering, leaving, or changing
470
+ * content MUST. Grounding that violates it is self-invalidating — serving the
471
+ * block writes delivery receipts, the next call's ranking reads them back, the
472
+ * wording moves ("today" → "delivered today"), the hash changes, and the full
473
+ * block is re-sent for records the agent already has.
474
+ *
475
+ * The projection, done line-wise over the one text the assembler produced:
476
+ * - a supplement that declared `hash_text` contributes that identity (kind +
477
+ * record id + the record's own content hash) instead of its rendered line —
478
+ * that is where reason text, slot labels and age words live;
479
+ * - every other line contributes verbatim, because it is already a property of
480
+ * the records (id, statement, scope, provenance tier, validation state);
481
+ * - each contiguous run of `- ` list lines is SORTED, so a pure re-ordering of
482
+ * the same records is not a change while a record entering or leaving the run
483
+ * still is.
484
+ *
485
+ * Nothing is removed from what the agent SEES — this string is never emitted.
486
+ * Never throws: on any surprise the caller gets `envelope.text` back, i.e. the
487
+ * original hash-the-presentation behaviour (a full block, never a wrong delta). */
488
+ export function deliveryDedupeInput(envelope, supplements = []) {
489
+ try {
490
+ const identity = new Map();
491
+ for (const s of supplements) {
492
+ if (s.hash_text === undefined)
493
+ continue;
494
+ // Key on the line exactly as the assembler rendered it, so the swap is a
495
+ // substitution and never a guess about the text's shape.
496
+ identity.set(`- supplemental/${s.kind} | ${clipHeadline(s.text, SUPPLEMENT_HEADLINE_CHARS)}`, `- supplemental/${s.kind} | identity\u0000${s.hash_text}`);
497
+ }
498
+ const projected = [];
499
+ let run = [];
500
+ const flush = () => { if (run.length) {
501
+ projected.push(...run.sort());
502
+ run = [];
503
+ } };
504
+ for (const line of envelope.text.split("\n")) {
505
+ if (!line.startsWith("- ")) {
506
+ flush();
507
+ projected.push(line);
508
+ continue;
509
+ }
510
+ run.push(identity.get(line) ?? line);
511
+ }
512
+ flush();
513
+ return projected.join("\n");
514
+ }
515
+ catch {
516
+ return envelope.text; // the projection is an optimization, never a correctness dependency
517
+ }
518
+ }
460
519
  /** Build the one envelope used by CLI, MCP, and the edit hook. */
461
520
  export function buildDeliveryEnvelope(ctx, options = {}) {
462
521
  const profile = options.profile ?? "builder";
@@ -742,7 +801,7 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
742
801
  ...(options.supplements ?? []),
743
802
  ].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.id.localeCompare(right.id));
744
803
  for (const [index, supplement] of supplementalCandidates.entries()) {
745
- const content = clipHeadline(supplement.text, 700);
804
+ const content = clipHeadline(supplement.text, SUPPLEMENT_HEADLINE_CHARS);
746
805
  if (!content) {
747
806
  supplements.push({ id: supplement.id, kind: supplement.kind, delivered: false, reason: "empty", rank: index + 1, token_cost: 0 });
748
807
  continue;
@@ -1,29 +1,199 @@
1
1
  import { currentForTopic, rejectedForTopic } from "./topics.js";
2
2
  const MARKER = /<!--\s*hunch:topic\s+([A-Za-z0-9._/-]+)(?:\s+(dec_[A-Za-z0-9]+))?\s*-->/g;
3
+ /** Expand the leading whitespace of a line to columns. A tab advances to the
4
+ * NEXT 4-column tab stop (CommonMark), not a flat 4 columns: after two
5
+ * spaces a tab is worth 2, so ` \t``` ` sits at column 4 like `\t``` `.
6
+ * Matching-only — offsets always come from the original line. */
7
+ function expandTabs(line) {
8
+ const ws = /^[ \t]*/.exec(line)[0];
9
+ if (!ws.includes("\t"))
10
+ return line;
11
+ let col = 0;
12
+ for (const c of ws)
13
+ col += c === "\t" ? 4 - (col % 4) : 1;
14
+ return " ".repeat(col) + line.slice(ws.length);
15
+ }
16
+ /** For every index i of `probe`, whether probe.slice(i) is a thematic break
17
+ * (`---`, `* * *`, `___`: ≤3 leading spaces, then ≥3 of one char separated by
18
+ * spaces only). Computed right-to-left in ONE pass so the list-marker walk
19
+ * below can ask the question at each nesting level without re-scanning the
20
+ * rest of the line each time — the difference between O(L) and O(L²) on a
21
+ * line that is nothing but list markers (`- - - …`, an adversarial doc). */
22
+ function thematicBreakSuffixes(probe) {
23
+ const L = probe.length;
24
+ // runFrom[i] = the break-so-far state of probe.slice(i) when it consists only
25
+ // of spaces and one repeated break char: its char, and how many were seen.
26
+ const ok = new Array(L + 1).fill(false);
27
+ let ch = "";
28
+ let count = 0;
29
+ let onlySpaceSoFar = true;
30
+ for (let i = L - 1; i >= 0; i--) {
31
+ const c = probe[i];
32
+ if (c === " " || c === "\t") {
33
+ // A space never breaks the run; it is allowed anywhere, including the
34
+ // ≤3 leading columns handled by the indent check at the call site.
35
+ ok[i] = ok[i + 1];
36
+ continue;
37
+ }
38
+ if (c === "-" || c === "*" || c === "_") {
39
+ if (onlySpaceSoFar) {
40
+ ch = c;
41
+ count = 1;
42
+ onlySpaceSoFar = false;
43
+ }
44
+ else if (c === ch)
45
+ count++;
46
+ // Two different break chars: every suffix starting at or before i now
47
+ // mixes them (`- - - * -` is five nested items, not a break), so stop.
48
+ else
49
+ return ok;
50
+ }
51
+ else {
52
+ // Any other char means no suffix starting at or before i is a break;
53
+ // ok is already false there, so stop.
54
+ return ok;
55
+ }
56
+ ok[i] = count >= 3;
57
+ }
58
+ return ok;
59
+ }
3
60
  /** Character ranges covered by fenced code blocks (``` or ~~~), so a
4
61
  * documentation EXAMPLE of a marker never registers as a live anchor.
5
62
  * CommonMark-lite: a fence of N chars (≤3 leading spaces) closes only on a
6
63
  * line of ≥N of the same char and nothing else; an unclosed fence runs to
7
64
  * EOF; a backtick fence's info string may not itself contain a backtick.
8
- * Expects LF-normalized text — see parseDocAnchors's normalization; a
9
- * caller that skips it re-opens the CRLF fence-detection bug. */
65
+ * The "≤3 leading spaces" is measured relative to the enclosing LIST ITEM's
66
+ * content offset, so a fence indented under `1. step` (issue #331) is still a
67
+ * fence and not an indented code block.
68
+ *
69
+ * A fence hosted in a list item ends where the ITEM ends: at the first
70
+ * non-blank line dedented below the item's content base. That is what the
71
+ * rendered document shows — the closing ``` of a sloppily indented example
72
+ * sits outside the item, so it terminates the item's fence and opens a new
73
+ * top-level one — and this scanner's job is to agree with the rendering a
74
+ * reader sees, not with the author's intent. In sloppy docs that differs from
75
+ * a flat fence scan; the rendering is the tiebreaker.
76
+ *
77
+ * Tabs expand to the next 4-column tab stop (not a flat 4), so `\t``` ` sits
78
+ * at column 4 exactly as a renderer places it. Offsets always come from the
79
+ * ORIGINAL line, never from the expanded probe.
80
+ *
81
+ * Deliberate limits: no lazy continuations and no blockquote containers — a
82
+ * `>` prefix is still read as ordinary text. With no list open the item stack
83
+ * is empty, the base is 0 and behaviour is the plain CommonMark-lite one.
84
+ * Expects LF-normalized text — see parseDocAnchors's normalization; a caller
85
+ * that skips it re-opens the CRLF fence-detection bug. */
10
86
  function fencedRanges(text) {
87
+ const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
11
88
  const ranges = [];
12
89
  let open = null;
90
+ // Content offsets (columns) of the currently open list items, outermost first.
91
+ const items = [];
13
92
  let offset = 0;
93
+ // Whether the previous line was a paragraph-continuation line: non-blank,
94
+ // and not itself a container/leaf opener. Only a bullet or a `1.`/`1)` with
95
+ // non-empty content may interrupt such a paragraph (CommonMark), so prose
96
+ // like "see item\n2. the second point" stays one paragraph.
97
+ let inParagraph = false;
14
98
  for (const line of text.split("\n")) {
15
- const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
16
- if (m) {
17
- const ch = m[1][0];
18
- if (!open) {
19
- if (!(ch === "`" && m[2].includes("`")))
20
- open = { ch, len: m[1].length, start: offset };
21
- }
22
- else if (ch === open.ch && m[1].length >= open.len && m[2].trim() === "") {
23
- ranges.push([open.start, offset + line.length]);
99
+ // Matching-only copy: tabs advance to the next 4-column tab stop.
100
+ const probe = expandTabs(line);
101
+ const indent = probe.length - probe.replace(/^ +/, "").length;
102
+ const blank = probe.trim() === "";
103
+ if (open) {
104
+ if (!blank && indent < open.base) {
105
+ // The list item holding the fence ended, which ends the fence too.
106
+ ranges.push([open.start, offset - 1]);
24
107
  open = null;
25
108
  }
109
+ else {
110
+ const m = FENCE.exec(probe.slice(Math.min(open.base, indent)));
111
+ if (m && m[1][0] === open.ch && m[1].length >= open.len && m[2].trim() === "") {
112
+ ranges.push([open.start, offset + line.length]);
113
+ open = null;
114
+ }
115
+ offset += line.length + 1;
116
+ continue; // the item stack is frozen while a fence is open
117
+ }
118
+ }
119
+ if (blank) {
120
+ offset += line.length + 1;
121
+ inParagraph = false;
122
+ continue; // a blank line neither opens nor closes an item here
123
+ }
124
+ // A dedent that closes an item also closes the paragraph inside it, so the
125
+ // line is a fresh block start: `1. one\n2. two` is two sibling items, not
126
+ // item one's paragraph being "interrupted" by a `2.`.
127
+ let popped = false;
128
+ while (items.length && items.at(-1) > indent) {
129
+ items.pop();
130
+ popped = true;
131
+ }
132
+ const isBreak = thematicBreakSuffixes(probe);
133
+ // Walk the line with an INDEX, peeling one list marker per step. Each step
134
+ // is O(marker) and the cheap checks are O(1), so a line costs O(length)
135
+ // however many markers it carries.
136
+ let base = items.at(-1) ?? 0;
137
+ let opened = false;
138
+ let thematic = false;
139
+ for (;;) {
140
+ // Leading spaces of this nesting level: ≤3, else it is indented content
141
+ // (and ` ---` is paragraph text or code, never a thematic break).
142
+ let i = base;
143
+ while (i < probe.length && probe[i] === " " && i - base < 4)
144
+ i++;
145
+ const lead = i - base;
146
+ if (lead > 3)
147
+ break;
148
+ if (isBreak[i]) {
149
+ thematic = true;
150
+ break;
151
+ } // thematic break, not a list marker
152
+ const c = probe[i];
153
+ let markerLen = 0;
154
+ if (c === "-" || c === "*" || c === "+")
155
+ markerLen = 1;
156
+ else if (c !== undefined && c >= "0" && c <= "9") {
157
+ let d = i;
158
+ while (d < probe.length && probe[d] >= "0" && probe[d] <= "9" && d - i < 9)
159
+ d++;
160
+ if (probe[d] === "." || probe[d] === ")")
161
+ markerLen = d - i + 1;
162
+ }
163
+ if (!markerLen)
164
+ break;
165
+ // Spaces between the marker and the item's content.
166
+ let s = i + markerLen;
167
+ while (s < probe.length && probe[s] === " ")
168
+ s++;
169
+ const gap = s - (i + markerLen);
170
+ const emptyItem = s >= probe.length;
171
+ if (!emptyItem && gap === 0)
172
+ break; // `-foo` / `1.foo` is not a marker
173
+ if (!opened && !popped && inParagraph && items.length === 0) {
174
+ // Interrupting a paragraph: only a bullet, or `1.`/`1)`, and never with
175
+ // empty content. `2. the second point` mid-prose stays paragraph text.
176
+ const ordinal = markerLen > 1 ? probe.slice(i, i + markerLen - 1) : "";
177
+ if (emptyItem || (ordinal !== "" && ordinal !== "1"))
178
+ break;
179
+ }
180
+ // ≥5 spaces after the marker starts an indented code block, so the
181
+ // item's content begins one column after the marker instead.
182
+ const w = gap >= 1 && gap <= 4 ? gap : 1;
183
+ base = base + lead + markerLen + w;
184
+ items.push(base);
185
+ opened = true;
186
+ continue; // `- 1. x` nests, and "- ```js" opens a fence on the marker line
187
+ }
188
+ const m = FENCE.exec(probe.slice(base));
189
+ if (m) {
190
+ const ch = m[1][0];
191
+ if (!(ch === "`" && m[2].includes("`")))
192
+ open = { ch, len: m[1].length, start: offset, base };
26
193
  }
194
+ // A fence line or a thematic break ends the paragraph it follows; ordinary
195
+ // text (including an item's own content) continues or starts one.
196
+ inParagraph = !m && !thematic;
27
197
  offset += line.length + 1;
28
198
  }
29
199
  if (open)
@@ -16,8 +16,13 @@ export function formatSearchHit(hit, record) {
16
16
  /** Render a StructureView as a compact orientation brief (hunch_structure). */
17
17
  export function formatStructure(v) {
18
18
  const NL = "\n";
19
- if (v.kind === "none")
19
+ if (v.kind === "none") {
20
+ // A real working-tree file with zero indexed symbols is a different answer from
21
+ // an unknown path — never tell the user a file we just stat'd doesn't exist.
22
+ if (v.realFile)
23
+ return `"${v.target}" is a real file, but the index holds no symbols for it — nothing to outline. Run hunch index if the repo changed, or hunch_why for its recorded decisions and invariants.`;
20
24
  return `Nothing indexed matches "${v.target}" — not a known file, directory, or symbol. Run hunch index if the repo changed, or hunch_query for fuzzy search.`;
25
+ }
21
26
  if (v.kind === "repo") {
22
27
  const out = [`# Repo structure (from the graph — no grep needed)`];
23
28
  if (v.components.length) {
@@ -13,14 +13,20 @@ export declare function pathsRelated(left: string, right: string): boolean;
13
13
  * Dockerfile, ...) that has zero tree-sitter symbols but is still a real,
14
14
  * known file. Derived entirely from already-loaded graph data — never the
15
15
  * filesystem — so the answer doesn't depend on untracked working-tree state
16
- * (a deleted-but-still-indexed path stays "real"; issue #299). */
16
+ * (a deleted-but-still-indexed path stays "real"; issue #299). It is therefore
17
+ * only HALF the "is this a real path" question: a real file with no symbols and
18
+ * no covering component is invisible here, so callers OR in `isRepoFile` as a
19
+ * last resort (issue #334) — `HunchStore.isKnownPath` is that composition. */
17
20
  export declare function isIndexedPath(target: string, symbolFiles: Iterable<string>, componentPaths: Iterable<readonly string[]>): boolean;
18
21
  /** Resolve symbols matching `target`, tiered: exact id > exact name > exact file >
19
- * (only when `target` is NOT a path already known to the index) segment-anchored
20
- * suffix. A real indexed file with zero symbols must return [] rather than fall
21
- * through to the suffix tier, which would leak an unrelated same-basename file's
22
- * records (issue #299) — callers compute `indexed` via `isIndexedPath` first so
23
- * the "is this a real path" question is answered identically everywhere. */
22
+ * (only when `target` is NOT a path already known to be real) segment-anchored
23
+ * suffix. A real file with zero symbols must return [] rather than fall through
24
+ * to the suffix tier, which would leak an unrelated same-basename file's records
25
+ * (issues #299/#334). Callers decide what counts as "real": one that ATTRIBUTES
26
+ * records silently (why(), the pre-edit hook) passes `HunchStore.isKnownPath`, the
27
+ * wider graph-or-working-tree answer that also covers a file about to be created;
28
+ * one that NAMES the file it resolved to (resolveNodeIds, structure()) passes the
29
+ * narrower `isRepoFile`, since glob coverage alone is not existence. */
24
30
  export declare function matchSymbolsTiered<S extends {
25
31
  id: string;
26
32
  file: string;