@davesheffer/hunch 1.43.0 → 1.45.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 (57) hide show
  1. package/README.md +16 -16
  2. package/dist/cli/index.js +267 -89
  3. package/dist/core/agenthook.d.ts +1 -0
  4. package/dist/core/agenthook.js +11 -0
  5. package/dist/core/changeProof.js +2 -1
  6. package/dist/core/commitrepair.d.ts +9 -3
  7. package/dist/core/commitrepair.js +23 -6
  8. package/dist/core/correction.d.ts +1 -1
  9. package/dist/core/correction.js +4 -3
  10. package/dist/core/docanchors.d.ts +11 -2
  11. package/dist/core/docanchors.js +232 -65
  12. package/dist/core/drift.d.ts +1 -1
  13. package/dist/core/drift.js +10 -3
  14. package/dist/core/eol.d.ts +2 -0
  15. package/dist/core/eol.js +6 -0
  16. package/dist/core/escalations.d.ts +20 -0
  17. package/dist/core/escalations.js +47 -0
  18. package/dist/core/hookText.js +1 -1
  19. package/dist/core/procstart.d.ts +15 -4
  20. package/dist/core/procstart.js +56 -18
  21. package/dist/core/runbookDraft.d.ts +7 -0
  22. package/dist/core/runbookDraft.js +43 -0
  23. package/dist/core/taskTouched.d.ts +2 -0
  24. package/dist/core/taskTouched.js +1 -1
  25. package/dist/extractors/git.d.ts +36 -6
  26. package/dist/extractors/git.js +86 -9
  27. package/dist/extractors/indexer.js +53 -14
  28. package/dist/extractors/k8sManifest.d.ts +31 -1
  29. package/dist/extractors/k8sManifest.js +133 -8
  30. package/dist/integrations/claudemd.d.ts +2 -0
  31. package/dist/integrations/claudemd.js +5 -3
  32. package/dist/integrations/gitignore.d.ts +23 -0
  33. package/dist/integrations/gitignore.js +48 -0
  34. package/dist/integrations/hooks.d.ts +15 -0
  35. package/dist/integrations/hooks.js +49 -1
  36. package/dist/integrations/mergeDriver.d.ts +2 -0
  37. package/dist/integrations/mergeDriver.js +2 -2
  38. package/dist/integrations/providers.d.ts +16 -0
  39. package/dist/integrations/providers.js +35 -6
  40. package/dist/integrations/scaffold.d.ts +5 -0
  41. package/dist/integrations/scaffold.js +13 -8
  42. package/dist/integrations/uninstall.d.ts +40 -0
  43. package/dist/integrations/uninstall.js +435 -0
  44. package/dist/mcp/leanToolList.d.ts +9 -0
  45. package/dist/mcp/leanToolList.js +89 -0
  46. package/dist/mcp/server.d.ts +2 -1
  47. package/dist/mcp/server.js +62 -6
  48. package/dist/serve/mcpHttp.js +2 -0
  49. package/dist/serve/writelock.d.ts +7 -0
  50. package/dist/serve/writelock.js +30 -3
  51. package/dist/store/changeLedger.d.ts +5 -2
  52. package/dist/store/changeLedger.js +88 -37
  53. package/dist/store/hunchStore.js +4 -1
  54. package/package.json +3 -6
  55. package/server.json +2 -2
  56. package/tooling/md1-benchmark.mjs +1 -1
  57. package/tooling/competitive-watch.mjs +0 -142
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export declare const HOOK_PROVIDERS: readonly ["claude", "codex", "vscode", "windsurf", "antigravity", "cursor"];
9
9
  export type HookProvider = (typeof HOOK_PROVIDERS)[number];
10
+ export declare function deliversPromptEvent(provider: HookProvider): boolean;
10
11
  export type HunchHookEvent = "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "UserPromptSubmit" | "SessionStart" | "SubagentStart" | "PreCompact" | "Stop";
11
12
  /** One file section of a Codex `apply_patch` payload. */
12
13
  export interface HunchPatchFile {
@@ -6,6 +6,17 @@
6
6
  * the same small, fail-open shape regardless of the assistant that emitted it.
7
7
  */
8
8
  export const HOOK_PROVIDERS = ["claude", "codex", "vscode", "windsurf", "antigravity", "cursor"];
9
+ /** Whether a provider's hooks deliver the user's prompt text (a UserPromptSubmit
10
+ * equivalent). Prompt-scoped behavior needs it; a provider without one gets that
11
+ * behavior at its session/orientation event instead. Antigravity's PreInvocation
12
+ * payload carries only conversation/invocation counters, no turn text. A Record,
13
+ * so a new provider must declare its answer. */
14
+ const PROMPT_EVENT = {
15
+ claude: true, codex: true, vscode: true, windsurf: true, cursor: true, antigravity: false,
16
+ };
17
+ export function deliversPromptEvent(provider) {
18
+ return PROMPT_EVENT[provider];
19
+ }
9
20
  function obj(value) {
10
21
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
11
22
  }
@@ -313,7 +313,8 @@ export function deriveChangeProof(root, store, baseRef, resultRef = "HEAD", opti
313
313
  // A diff cut at MAX_DIFF_BYTES is a prefix: content-matched blocking rules over the
314
314
  // files it omits fail closed rather than read as compliant (dec_20db57c576).
315
315
  diffStatus: diff.gaps.length ? { incomplete: `the guard diff exceeded ${MAX_DIFF_BYTES} bytes and was cut`, truncated: true } : undefined,
316
- lastChange: (path) => lastChangeAt(root, change.head_revision, path),
316
+ // Freshness as of the base: the change under proof must not make its own guard stale (#426).
317
+ lastChange: (path) => lastChangeAt(root, change.base_revision, path),
317
318
  });
318
319
  const allStrictBlockerIds = sortedUnique([
319
320
  ...guardReport.direct.filter((item) => item.strictBlocks).map((item) => item.id),
@@ -30,9 +30,15 @@ export interface CommitRepairPlan {
30
30
  * doesn't resolve at all ("unresolvable") is out of repair scope, and a git
31
31
  * failure ("unknown") must never be mistaken for repair-eligible. */
32
32
  export declare function orphanedCommitDecisions(decisions: readonly Decision[], status: (sha: string) => CommitRepairStatus): Decision[];
33
- /** Match each orphaned decision to exactly one candidate whose changed files are
34
- * a superset of the decision's related_files (non-empty, no globs). */
35
- export declare function planCommitRepair(orphaned: readonly Decision[], candidates: readonly CommitCandidate[]): CommitRepairPlan;
33
+ /** Match each orphaned decision to exactly one candidate. A candidate with the
34
+ * same `git patch-id --stable` as the orphaned commit (a rebase- or
35
+ * single-commit squash-merge of it) is the strong match and wins. Only when
36
+ * no candidate shares the patch-id does it fall back to the one candidate
37
+ * whose changed files are a superset of the decision's related_files
38
+ * (non-empty, no globs) -- needed for a squash of several commits, whose
39
+ * patch-id never equals any one of them, but weak: shared files like
40
+ * CHANGELOG.md often have one wrong superset (#450). */
41
+ export declare function planCommitRepair(orphaned: readonly Decision[], candidates: readonly CommitCandidate[], patchIdOf?: (sha: string) => string | null): CommitRepairPlan;
36
42
  /** Whether a queued rewrite is still worth ASKING about: the decision it
37
43
  * names still exists, still cites the commit the entry expects to replace (a
38
44
  * decision that already moved on would make repairDecisionCommit's own
@@ -4,9 +4,10 @@
4
4
  * A squash-merge produces one new commit whose parent is the pre-merge target
5
5
  * tip — the original per-commit SHAs it squashed are never ancestors of it.
6
6
  * This module matches an orphaned decision to the ONE newly-merged commit
7
- * whose changed files are a superset of the decision's related_files. Zero or
8
- * multiple qualifying candidates means "don't guess" — same discipline as
9
- * repair.ts's rename-repair.
7
+ * with the same patch-id, or failing that the ONE whose changed files are a
8
+ * superset of the decision's related_files. Zero or multiple qualifying
9
+ * candidates means "don't guess" — same discipline as repair.ts's
10
+ * rename-repair.
10
11
  */
11
12
  import { createHash } from "node:crypto";
12
13
  import { replaceExact } from "./refrepair.js";
@@ -33,11 +34,27 @@ export function orphanedCommitDecisions(decisions, status) {
33
34
  return status(d.commit) === "orphaned";
34
35
  });
35
36
  }
36
- /** Match each orphaned decision to exactly one candidate whose changed files are
37
- * a superset of the decision's related_files (non-empty, no globs). */
38
- export function planCommitRepair(orphaned, candidates) {
37
+ /** Match each orphaned decision to exactly one candidate. A candidate with the
38
+ * same `git patch-id --stable` as the orphaned commit (a rebase- or
39
+ * single-commit squash-merge of it) is the strong match and wins. Only when
40
+ * no candidate shares the patch-id does it fall back to the one candidate
41
+ * whose changed files are a superset of the decision's related_files
42
+ * (non-empty, no globs) -- needed for a squash of several commits, whose
43
+ * patch-id never equals any one of them, but weak: shared files like
44
+ * CHANGELOG.md often have one wrong superset (#450). */
45
+ export function planCommitRepair(orphaned, candidates, patchIdOf = () => null) {
39
46
  const rewrites = [];
40
47
  for (const d of orphaned) {
48
+ const patchId = patchIdOf(d.commit);
49
+ if (patchId) {
50
+ const same = candidates.filter((c) => patchIdOf(c.sha) === patchId);
51
+ if (same.length === 1) {
52
+ rewrites.push({ id: d.id, from: d.commit, to: same[0].sha });
53
+ continue;
54
+ }
55
+ if (same.length > 1)
56
+ continue; // ambiguous: never guess
57
+ }
41
58
  const files = (d.related_files ?? []).filter(isExactPath);
42
59
  if (!files.length)
43
60
  continue;
@@ -30,7 +30,7 @@ export interface CorrectionInput {
30
30
  * enough — any agent can mint and consume one. Determines the TIER, never whether
31
31
  * the write lands: an un-vouched correction still records immediately and still
32
32
  * surfaces at edit time and in CI. Only the authority to DENY waits for a countersign
33
- * (`hunch review --confirm <id>`). */
33
+ * (`hunch review --confirm <id>`). Omitted means false. */
34
34
  vouched?: boolean;
35
35
  }
36
36
  /** Default rationales, exported so a later human countersign can replace the testimony
@@ -5,8 +5,8 @@
5
5
  * agent hook so they are unit-testable without spinning either up:
6
6
  * - looksLikeCorrection(): does a user prompt read like "no / that's wrong /
7
7
  * never do X" — the cue to nudge the agent to persist it.
8
- * - buildCorrectionConstraint(): mint the Constraint record (human-confirmed,
9
- * scoped conservatively) that the pre-edit hook + CI guard then enforce.
8
+ * - buildCorrectionConstraint(): mint the Constraint record (human-confirmed
9
+ * only when vouched, scoped conservatively) that the pre-edit hook + CI guard then enforce.
10
10
  */
11
11
  import { isAbsolute } from "node:path";
12
12
  import { constraintId } from "./ids.js";
@@ -108,7 +108,8 @@ export function buildCorrectionConstraint(input, now) {
108
108
  // still recorded immediately and still held against every assistant at edit time and
109
109
  // in CI — Never Twice keeps its promise. What waits for a countersign is only the
110
110
  // authority to DENY.
111
- const vouched = input.vouched !== false;
111
+ // Fails closed: an omitted flag is testimony, never a signature.
112
+ const vouched = input.vouched === true;
112
113
  if (!vouched && severity === "blocking")
113
114
  severity = "warning";
114
115
  return {
@@ -22,9 +22,18 @@ export interface DocAnchor {
22
22
  line: number;
23
23
  }
24
24
  /** Parse every hunch:topic marker out of a markdown document. Markers inside
25
- * fenced code blocks or inline code spans are examples, not declarations,
26
- * and are skipped. */
25
+ * code blocks or inline code spans are examples, not declarations, and are
26
+ * skipped. */
27
27
  export declare function parseDocAnchors(text: string): DocAnchor[];
28
+ /** parseDocAnchors plus what drift reports about the document: a fence that
29
+ * never closes hides every marker below it, almost always by mistake (#381). */
30
+ export declare function scanDocAnchors(text: string): {
31
+ anchors: DocAnchor[];
32
+ unclosedFence: {
33
+ line: number;
34
+ hidden: number;
35
+ } | null;
36
+ };
28
37
  /** Pre-edit grounding for a markdown document that carries topic anchors: the
29
38
  * CURRENT decision per declared topic (graph over prose), what it rejected,
30
39
  * and a stale-pin warning the editor can heal inline. Empty when no anchor
@@ -58,14 +58,18 @@ function thematicBreakSuffixes(probe) {
58
58
  }
59
59
  return ok;
60
60
  }
61
- /** Character ranges covered by fenced code blocks (``` or ~~~), so a
62
- * documentation EXAMPLE of a marker never registers as a live anchor.
63
- * CommonMark-lite: a fence of N chars (≤3 leading spaces) closes only on a
64
- * line of ≥N of the same char and nothing else; an unclosed fence runs to
65
- * EOF; a backtick fence's info string may not itself contain a backtick.
66
- * The "≤3 leading spaces" is measured relative to the enclosing LIST ITEM's
67
- * content offset, so a fence indented under `1. step` (issue #331) is still a
68
- * fence and not an indented code block.
61
+ /** Character ranges covered by code blocks — fenced (``` or ~~~) and indented —
62
+ * so a documentation EXAMPLE of a marker never registers as a live anchor.
63
+ * A hand scanner, not a markdown parser: it answers only "is this offset inside
64
+ * code as a renderer shows it?", line by line and linear in each line's length.
65
+ *
66
+ * Fences, CommonMark-lite: a fence of N chars (≤3 leading spaces) closes only on
67
+ * a line of ≥N of the same char and nothing else; an unclosed fence runs to EOF
68
+ * (reported as `unclosed`, which drift surfaces — issue #381); a backtick
69
+ * fence's info string may not itself contain a backtick. The "≤3 leading
70
+ * spaces" is measured relative to the enclosing LIST ITEM's content offset, so a
71
+ * fence indented under `1. step` (issue #331) is still a fence and not an
72
+ * indented code block.
69
73
  *
70
74
  * A fence hosted in a list item ends where the ITEM ends: at the first
71
75
  * non-blank line dedented below the item's content base. That is what the
@@ -75,134 +79,286 @@ function thematicBreakSuffixes(probe) {
75
79
  * reader sees, not with the author's intent. In sloppy docs that differs from
76
80
  * a flat fence scan; the rendering is the tiebreaker.
77
81
  *
82
+ * Also as a renderer reads them (issue #381):
83
+ * - indented code: ≥4 columns past the container's content, not continuing a
84
+ * paragraph, runs until a line dedents below that (blank lines inside it);
85
+ * - blockquotes: `>` prefixes are peeled first, so `> ```` opens a fence inside
86
+ * the quote, and a line that leaves the quote ends it;
87
+ * - HTML blocks (CommonMark types 1–6: script/pre/style/textarea, comments,
88
+ * `<?`, `<!X`, CDATA, block-level tags): their lines are raw HTML, so a ``` or
89
+ * an indented line inside one opens no code block. A marker inside an HTML
90
+ * block stays LIVE: it is an HTML comment there too, not shown as an example;
91
+ * - lazy continuation: a paragraph line dedented out of its list item or quote
92
+ * that starts no block of its own keeps that item/quote open.
93
+ *
78
94
  * Tabs expand to the next 4-column tab stop (not a flat 4), so `\t``` ` sits
79
95
  * at column 4 exactly as a renderer places it. Offsets always come from the
80
96
  * ORIGINAL line, never from the expanded probe.
81
97
  *
82
- * Deliberate limits: no lazy continuations and no blockquote containers — a
83
- * `>` prefix is still read as ordinary text. With no list open the item stack
84
- * is empty, the base is 0 and behaviour is the plain CommonMark-lite one.
85
- * Expects LF-normalized text — see parseDocAnchors's normalization; a caller
86
- * that skips it re-opens the CRLF fence-detection bug. */
87
- function fencedRanges(text) {
88
- const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
98
+ * Deliberate limits, each a case where it can still disagree with a renderer:
99
+ * HTML block type 7 (a lone arbitrary tag line) reads as text; a blockquote
100
+ * nested INSIDE a list item is read as a top-level quote; after a tab right
101
+ * after a `>`, later tabs on that line expand from the quote's content, not
102
+ * the line. With no container
103
+ * open the base is 0 and behaviour is the plain CommonMark-lite one.
104
+ * Expects LF-normalized, BOM-free text — see parseDocAnchors's normalization;
105
+ * a caller that skips it re-opens the CRLF fence-detection bug. */
106
+ function codeRanges(text) {
89
107
  const ranges = [];
90
108
  let open = null;
91
- // Content offsets (columns) of the currently open list items, outermost first.
109
+ // Open list items, outermost first: the quote depth each lives in and its content column.
92
110
  const items = [];
111
+ let curQ = 0; // quote depth of the current block
93
112
  let offset = 0;
113
+ let lineNo = 0;
94
114
  // Whether the previous line was a paragraph-continuation line: non-blank,
95
115
  // and not itself a container/leaf opener. Only a bullet or a `1.`/`1)` with
96
116
  // non-empty content may interrupt such a paragraph (CommonMark), so prose
97
117
  // like "see item\n2. the second point" stays one paragraph.
98
118
  let inParagraph = false;
119
+ const closeOpen = (lineStart) => {
120
+ if (open?.kind === "fence")
121
+ ranges.push([open.start, lineStart - 1]);
122
+ else if (open?.kind === "indent")
123
+ ranges.push([open.start, open.end]);
124
+ open = null;
125
+ };
99
126
  for (const line of text.split("\n")) {
127
+ lineNo++;
128
+ const lineStart = offset;
129
+ offset += line.length + 1;
100
130
  // Matching-only copy: tabs advance to the next 4-column tab stop.
101
131
  const probe = expandTabs(line);
102
- const indent = probe.length - probe.replace(/^ +/, "").length;
103
- const blank = probe.trim() === "";
104
132
  if (open) {
105
- if (!blank && indent < open.base) {
106
- // The list item holding the fence ended, which ends the fence too.
107
- ranges.push([open.start, offset - 1]);
108
- open = null;
133
+ const held = peelQuotes(probe, open.q);
134
+ if (held.q < open.q) {
135
+ closeOpen(lineStart); // the quote holding the block ended, which ends the block too
109
136
  }
110
137
  else {
111
- const m = FENCE.exec(probe.slice(Math.min(open.base, indent)));
112
- if (m && m[1][0] === open.ch && m[1].length >= open.len && m[2].trim() === "") {
113
- ranges.push([open.start, offset + line.length]);
114
- open = null;
138
+ const rest = held.rest;
139
+ const indent = leadingSpaces(rest);
140
+ const blank = rest.trim() === "";
141
+ if (open.kind === "fence") {
142
+ if (!blank && indent < open.base) {
143
+ closeOpen(lineStart); // the list item holding the fence ended, which ends the fence too
144
+ }
145
+ else {
146
+ const m = FENCE.exec(rest.slice(Math.min(open.base, indent)));
147
+ if (m && m[1][0] === open.ch && m[1].length >= open.len && m[2].trim() === "") {
148
+ ranges.push([open.start, lineStart + line.length]);
149
+ open = null;
150
+ }
151
+ continue; // the container stacks are frozen while a fence is open
152
+ }
153
+ }
154
+ else if (open.kind === "indent") {
155
+ if (blank)
156
+ continue;
157
+ if (indent >= open.base + 4) {
158
+ open.end = lineStart + line.length;
159
+ continue;
160
+ }
161
+ closeOpen(lineStart);
162
+ }
163
+ else {
164
+ if (open.end === "blank" ? blank : open.end.test(rest)) {
165
+ open = null;
166
+ inParagraph = false;
167
+ }
168
+ continue;
115
169
  }
116
- offset += line.length + 1;
117
- continue; // the item stack is frozen while a fence is open
118
170
  }
119
171
  }
120
- if (blank) {
121
- offset += line.length + 1;
172
+ const { q, rest } = peelQuotes(probe, Infinity);
173
+ const indent = leadingSpaces(rest);
174
+ if (rest.trim() === "") {
175
+ while (items.length && items.at(-1).q > q)
176
+ items.pop();
177
+ curQ = q;
122
178
  inParagraph = false;
123
179
  continue; // a blank line neither opens nor closes an item here
124
180
  }
181
+ const isBreak = thematicBreakSuffixes(rest);
182
+ // Lazy continuation: a paragraph line that falls out of its item or quote but
183
+ // starts no block of its own still belongs to that paragraph, so every
184
+ // container stays open (`- foo\nbar` — bar is item text).
185
+ const top = items.at(-1);
186
+ const leaves = q < curQ || (top !== undefined && top.q === q && top.base > indent);
187
+ if (inParagraph && leaves && !startsBlock(rest, indent, isBreak))
188
+ continue;
125
189
  // A dedent that closes an item also closes the paragraph inside it, so the
126
190
  // line is a fresh block start: `1. one\n2. two` is two sibling items, not
127
191
  // item one's paragraph being "interrupted" by a `2.`.
128
- let popped = false;
129
- while (items.length && items.at(-1) > indent) {
192
+ let popped = q !== curQ;
193
+ while (items.length && (items.at(-1).q !== q || items.at(-1).base > indent)) {
130
194
  items.pop();
131
195
  popped = true;
132
196
  }
133
- const isBreak = thematicBreakSuffixes(probe);
197
+ curQ = q;
134
198
  // Walk the line with an INDEX, peeling one list marker per step. Each step
135
199
  // is O(marker) and the cheap checks are O(1), so a line costs O(length)
136
200
  // however many markers it carries.
137
- let base = items.at(-1) ?? 0;
201
+ let base = items.at(-1)?.base ?? 0;
138
202
  let opened = false;
139
203
  let thematic = false;
204
+ let indented = false;
205
+ let at = base;
140
206
  for (;;) {
141
207
  // Leading spaces of this nesting level: ≤3, else it is indented content
142
208
  // (and ` ---` is paragraph text or code, never a thematic break).
143
209
  let i = base;
144
- while (i < probe.length && probe[i] === " " && i - base < 4)
210
+ while (i < rest.length && rest[i] === " " && i - base < 4)
145
211
  i++;
146
- const lead = i - base;
147
- if (lead > 3)
212
+ at = i;
213
+ if (i - base > 3) {
214
+ indented = true;
148
215
  break;
216
+ }
149
217
  if (isBreak[i]) {
150
218
  thematic = true;
151
219
  break;
152
220
  } // thematic break, not a list marker
153
- const c = probe[i];
221
+ const c = rest[i];
154
222
  let markerLen = 0;
155
223
  if (c === "-" || c === "*" || c === "+")
156
224
  markerLen = 1;
157
225
  else if (c !== undefined && c >= "0" && c <= "9") {
158
226
  let d = i;
159
- while (d < probe.length && probe[d] >= "0" && probe[d] <= "9" && d - i < 9)
227
+ while (d < rest.length && rest[d] >= "0" && rest[d] <= "9" && d - i < 9)
160
228
  d++;
161
- if (probe[d] === "." || probe[d] === ")")
229
+ if (rest[d] === "." || rest[d] === ")")
162
230
  markerLen = d - i + 1;
163
231
  }
164
232
  if (!markerLen)
165
233
  break;
166
234
  // Spaces between the marker and the item's content.
167
235
  let s = i + markerLen;
168
- while (s < probe.length && probe[s] === " ")
236
+ while (s < rest.length && rest[s] === " ")
169
237
  s++;
170
238
  const gap = s - (i + markerLen);
171
- const emptyItem = s >= probe.length;
239
+ const emptyItem = s >= rest.length;
172
240
  if (!emptyItem && gap === 0)
173
241
  break; // `-foo` / `1.foo` is not a marker
174
242
  if (!opened && !popped && inParagraph && items.length === 0) {
175
243
  // Interrupting a paragraph: only a bullet, or `1.`/`1)`, and never with
176
244
  // empty content. `2. the second point` mid-prose stays paragraph text.
177
- const ordinal = markerLen > 1 ? probe.slice(i, i + markerLen - 1) : "";
245
+ const ordinal = markerLen > 1 ? rest.slice(i, i + markerLen - 1) : "";
178
246
  if (emptyItem || (ordinal !== "" && ordinal !== "1"))
179
247
  break;
180
248
  }
181
249
  // ≥5 spaces after the marker starts an indented code block, so the
182
250
  // item's content begins one column after the marker instead.
183
251
  const w = gap >= 1 && gap <= 4 ? gap : 1;
184
- base = base + lead + markerLen + w;
185
- items.push(base);
252
+ base = i + markerLen + w;
253
+ items.push({ q, base });
186
254
  opened = true;
187
255
  continue; // `- 1. x` nests, and "- ```js" opens a fence on the marker line
188
256
  }
189
- const m = FENCE.exec(probe.slice(base));
257
+ if (indented) {
258
+ // An indented code block cannot interrupt a paragraph: there it is text.
259
+ if (!inParagraph || opened) {
260
+ open = { kind: "indent", start: lineStart, end: lineStart + line.length, base, q };
261
+ inParagraph = false;
262
+ }
263
+ continue;
264
+ }
265
+ const content = rest.slice(at);
266
+ const html = htmlBlockEnd(content);
267
+ if (html) {
268
+ if (html === "blank" || !html.test(content))
269
+ open = { kind: "html", end: html, q };
270
+ inParagraph = false;
271
+ continue;
272
+ }
273
+ const m = FENCE.exec(rest.slice(base));
190
274
  if (m) {
191
275
  const ch = m[1][0];
192
276
  if (!(ch === "`" && m[2].includes("`")))
193
- open = { ch, len: m[1].length, start: offset, base };
277
+ open = { kind: "fence", ch, len: m[1].length, start: lineStart, base, q, line: lineNo };
194
278
  }
195
- // A fence line or a thematic break ends the paragraph it follows; ordinary
279
+ // A fence line, a thematic break or a heading (ATX, or a `===` setext
280
+ // underline beneath a paragraph) ends the paragraph it follows; ordinary
196
281
  // text (including an item's own content) continues or starts one.
197
- inParagraph = !m && !thematic;
198
- offset += line.length + 1;
282
+ const setext = inParagraph && /^=+[ \t]*$/.test(content);
283
+ inParagraph = !m && !thematic && !setext && content.trim() !== "" && !/^#{1,6}(?:[ \t]|$)/.test(content);
199
284
  }
200
- if (open)
201
- ranges.push([open.start, text.length]);
202
- return ranges;
285
+ let unclosed = null;
286
+ const last = open;
287
+ if (last?.kind === "fence") {
288
+ ranges.push([last.start, text.length]);
289
+ unclosed = { start: last.start, line: last.line };
290
+ }
291
+ else if (last?.kind === "indent")
292
+ ranges.push([last.start, last.end]);
293
+ return { ranges, unclosed };
294
+ }
295
+ const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
296
+ const leadingSpaces = (s) => s.length - s.replace(/^ +/, "").length;
297
+ /** Peel up to `max` blockquote markers (`>`, ≤3 spaces before it, one optional
298
+ * space after) off a tab-expanded line: the quote depth and what follows. */
299
+ function peelQuotes(probe, max) {
300
+ let q = 0;
301
+ let i = 0;
302
+ let col = 0;
303
+ let pad = 0;
304
+ while (q < max) {
305
+ let j = i;
306
+ while (j < probe.length && probe[j] === " " && j - i < 3)
307
+ j++;
308
+ if (probe[j] !== ">")
309
+ break;
310
+ col += j - i + 1;
311
+ i = j + 1;
312
+ pad = 0;
313
+ if (probe[i] === " ") {
314
+ i++;
315
+ col++;
316
+ }
317
+ else if (probe[i] === "\t") {
318
+ // The tab's first column is the quote's optional space; the rest of it is
319
+ // content indentation (CommonMark 5.1). `>\t<!-- … -->` is a paragraph, not code.
320
+ const width = 4 - (col % 4);
321
+ i++;
322
+ col += width;
323
+ pad = width - 1;
324
+ }
325
+ q++;
326
+ }
327
+ return q ? { q, rest: " ".repeat(pad) + expandTabs(probe.slice(i)) } : { q, rest: probe };
328
+ }
329
+ /** Whether a line (≤3 columns in) opens a block of its own, which a lazy
330
+ * continuation line by definition does not. */
331
+ function startsBlock(rest, indent, isBreak) {
332
+ if (indent > 3)
333
+ return false;
334
+ const s = rest.slice(indent);
335
+ return isBreak[indent] === true || FENCE.test(rest) || /^#{1,6}(?:[ \t]|$)/.test(s) || s[0] === ">"
336
+ || htmlBlockEnd(s) !== null || /^(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/.test(s);
337
+ }
338
+ const HTML_BLOCK_TAGS = new Set(("address article aside base basefont blockquote body caption center col colgroup dd details dialog dir div dl dt "
339
+ + "fieldset figcaption figure footer form frame frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend li link main menu menuitem "
340
+ + "nav noframes ol optgroup option p param search section summary table tbody td tfoot th thead title tr track ul").split(" "));
341
+ /** The end condition of the HTML block (CommonMark types 1–6) a line opens:
342
+ * a pattern that closes it on a later (or the same) line, "blank" for a block
343
+ * that runs to the next blank line, or null when the line opens none. */
344
+ function htmlBlockEnd(s) {
345
+ if (s[0] !== "<")
346
+ return null;
347
+ if (/^<(?:script|pre|style|textarea)(?:[\s>]|$)/i.test(s))
348
+ return /<\/(?:script|pre|style|textarea)>/i;
349
+ if (s.startsWith("<!--"))
350
+ return /-->/;
351
+ if (s.startsWith("<?"))
352
+ return /\?>/;
353
+ if (s.startsWith("<![CDATA["))
354
+ return /\]\]>/;
355
+ if (/^<![A-Za-z]/.test(s))
356
+ return />/;
357
+ const tag = /^<\/?([A-Za-z][A-Za-z0-9-]*)(?:[\s>]|\/>|$)/.exec(s);
358
+ return tag && HTML_BLOCK_TAGS.has(tag[1].toLowerCase()) ? "blank" : null;
203
359
  }
204
360
  /** Character ranges covered by inline code spans (`…`), same rationale as
205
- * fencedRanges: prose quoting a marker in backticks is showing an example.
361
+ * codeRanges: prose quoting a marker in backticks is showing an example.
206
362
  * CommonMark-lite: an opener run pairs with the next run of the SAME length
207
363
  * on the same line; unpaired runs never open a span.
208
364
  * Expects LF-normalized text — see parseDocAnchors's normalization.
@@ -229,28 +385,39 @@ function inlineSpanRanges(text) {
229
385
  return ranges;
230
386
  }
231
387
  /** Parse every hunch:topic marker out of a markdown document. Markers inside
232
- * fenced code blocks or inline code spans are examples, not declarations,
233
- * and are skipped. */
388
+ * code blocks or inline code spans are examples, not declarations, and are
389
+ * skipped. */
234
390
  export function parseDocAnchors(text) {
235
- // fencedRanges is CRLF-sensitive (its fence-line regex's `.` excludes \r, so
391
+ return scanDocAnchors(text).anchors;
392
+ }
393
+ /** parseDocAnchors plus what drift reports about the document: a fence that
394
+ * never closes hides every marker below it, almost always by mistake (#381). */
395
+ export function scanDocAnchors(text) {
396
+ // codeRanges is CRLF-sensitive (its fence-line regex's `.` excludes \r, so
236
397
  // "```\r" never matched at all on a CRLF checkout); inlineSpanRanges is
237
398
  // lone-CR-sensitive (split("\n") doesn't split a bare CR — see its
238
399
  // docblock). Either way an example marker inside a fence/span registered as
239
400
  // a live, pinned anchor. Normalizing both CRLF and lone CR once here keeps
240
- // fencedRanges/inlineSpanRanges/MARKER offsets consistent with each other
241
- // and with the line numbers reported below.
242
- text = normalizeEol(text);
243
- const out = [];
244
- const skip = [...fencedRanges(text), ...inlineSpanRanges(text)];
401
+ // codeRanges/inlineSpanRanges/MARKER offsets consistent with each other
402
+ // and with the line numbers reported below. A leading BOM is dropped too, so
403
+ // line 1 starts at column 0 (#381); it is one char on line 1, so line numbers hold.
404
+ text = normalizeEol(text).replace(/^\uFEFF/, "");
405
+ const anchors = [];
406
+ const code = codeRanges(text);
407
+ const skip = [...code.ranges, ...inlineSpanRanges(text)];
408
+ let hidden = 0;
245
409
  MARKER.lastIndex = 0;
246
410
  let m;
247
411
  while ((m = MARKER.exec(text))) {
248
412
  const at = m.index;
249
- if (skip.some(([s, e]) => at >= s && at <= e))
413
+ if (skip.some(([s, e]) => at >= s && at <= e)) {
414
+ if (code.unclosed && at > code.unclosed.start)
415
+ hidden++;
250
416
  continue;
251
- out.push({ topic: m[1], pin: m[2] ?? null, line: text.slice(0, at).split("\n").length });
417
+ }
418
+ anchors.push({ topic: m[1], pin: m[2] ?? null, line: text.slice(0, at).split("\n").length });
252
419
  }
253
- return out;
420
+ return { anchors, unclosedFence: code.unclosed && hidden ? { line: code.unclosed.line, hidden } : null };
254
421
  }
255
422
  const clip = (s, n = 220) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s);
256
423
  /** Pre-edit grounding for a markdown document that carries topic anchors: the
@@ -1,5 +1,5 @@
1
1
  import type { HunchStore } from "../store/hunchStore.js";
2
- export declare const DRIFT_KINDS: readonly ["dead-ref", "supersede", "doc-stale", "anchor-stale", "doc-anchor-stale", "doc-anchor-dangling", "wiki-stale", "finding-stale", "premise-stale", "commit-unresolvable", "madr-stale", "madr-edited", "madr-orphan"];
2
+ export declare const DRIFT_KINDS: readonly ["dead-ref", "supersede", "doc-stale", "anchor-stale", "doc-anchor-stale", "doc-anchor-dangling", "doc-fence-unclosed", "wiki-stale", "finding-stale", "premise-stale", "commit-unresolvable", "madr-stale", "madr-edited", "madr-orphan"];
3
3
  export type DriftKind = typeof DRIFT_KINDS[number];
4
4
  export interface DriftFinding {
5
5
  kind: DriftKind;
@@ -22,12 +22,12 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
22
22
  import { toPosixTarget } from "./paths.js";
23
23
  import { currentForTopic, isLive } from "./topics.js";
24
24
  import { evaluatePremises } from "./premises.js";
25
- import { parseDocAnchors } from "./docanchors.js";
25
+ import { scanDocAnchors } from "./docanchors.js";
26
26
  import { markdownDocs, STALE_MARKER, SRC_REF } from "./docscan.js";
27
27
  import { computeWikiDrift } from "../wiki/wiki.js";
28
28
  import { computeMadrDrift } from "../integrations/madrManifest.js";
29
29
  import { commitsExist, isGitRepo } from "../extractors/git.js";
30
- export const DRIFT_KINDS = ["dead-ref", "supersede", "doc-stale", "anchor-stale", "doc-anchor-stale", "doc-anchor-dangling", "wiki-stale", "finding-stale", "premise-stale", "commit-unresolvable", "madr-stale", "madr-edited", "madr-orphan"];
30
+ export const DRIFT_KINDS = ["dead-ref", "supersede", "doc-stale", "anchor-stale", "doc-anchor-stale", "doc-anchor-dangling", "doc-fence-unclosed", "wiki-stale", "finding-stale", "premise-stale", "commit-unresolvable", "madr-stale", "madr-edited", "madr-orphan"];
31
31
  export function computeDrift(store, root, deps = {}) {
32
32
  const findings = [];
33
33
  const decisions = store.recs("decisions");
@@ -157,7 +157,14 @@ export function computeDrift(store, root, deps = {}) {
157
157
  // been superseded (doc-anchor-stale, the CI-gateable one) or that doesn't
158
158
  // exist (doc-anchor-dangling). Unpinned markers only ground the pre-edit
159
159
  // hook — an explicit pin is the ONLY thing that can fire drift here.
160
- for (const a of parseDocAnchors(text)) {
160
+ // A fence that never closes hides every marker below it (doc-fence-unclosed,
161
+ // advisory): almost always an authoring mistake that disables real anchors.
162
+ const scan = scanDocAnchors(text);
163
+ if (scan.unclosedFence) {
164
+ const { line, hidden } = scan.unclosedFence;
165
+ findings.push({ kind: "doc-fence-unclosed", id: doc.rel, detail: `fence opened at line ${line} never closes; ${hidden} marker(s) below it are hidden` });
166
+ }
167
+ for (const a of scan.anchors) {
161
168
  if (!a.pin)
162
169
  continue;
163
170
  const pinned = byId.get(a.pin);
@@ -2,6 +2,8 @@
2
2
  export declare function normalizeEol(text: string): string;
3
3
  /** Remove the CR retained by split("\n") without changing source offsets. */
4
4
  export declare function lineContent(line: string): string;
5
+ /** Length of the line break (CRLF, CR, or LF) starting at `at`, or 0. */
6
+ export declare function eolLengthAt(text: string, at: number): number;
5
7
  /** Read logical lines while keeping offsets in the original UTF-16 source. */
6
8
  export declare function sourceLines(text: string): Generator<{
7
9
  content: string;