@davesheffer/hunch 1.42.0 → 1.44.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 (56) hide show
  1. package/README.md +14 -0
  2. package/dist/cli/index.js +206 -37
  3. package/dist/core/changeProof.js +2 -1
  4. package/dist/core/commitrepair.d.ts +9 -3
  5. package/dist/core/commitrepair.js +23 -6
  6. package/dist/core/correction.d.ts +1 -1
  7. package/dist/core/correction.js +4 -3
  8. package/dist/core/delivery.d.ts +12 -0
  9. package/dist/core/delivery.js +4 -4
  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/hookcache.d.ts +22 -0
  17. package/dist/core/hookcache.js +53 -2
  18. package/dist/core/procstart.d.ts +15 -4
  19. package/dist/core/procstart.js +56 -18
  20. package/dist/core/publication.js +17 -3
  21. package/dist/core/runbookDraft.d.ts +7 -0
  22. package/dist/core/runbookDraft.js +43 -0
  23. package/dist/core/taskReportHook.js +1 -1
  24. package/dist/core/taskSelection.d.ts +67 -0
  25. package/dist/core/taskSelection.js +196 -0
  26. package/dist/core/taskTouched.d.ts +2 -0
  27. package/dist/core/taskTouched.js +1 -1
  28. package/dist/extractors/git.d.ts +36 -6
  29. package/dist/extractors/git.js +86 -9
  30. package/dist/extractors/indexer.js +53 -14
  31. package/dist/extractors/k8sManifest.d.ts +31 -1
  32. package/dist/extractors/k8sManifest.js +133 -8
  33. package/dist/integrations/claudemd.d.ts +2 -0
  34. package/dist/integrations/claudemd.js +4 -2
  35. package/dist/integrations/gitignore.d.ts +7 -0
  36. package/dist/integrations/gitignore.js +18 -0
  37. package/dist/integrations/hooks.d.ts +15 -0
  38. package/dist/integrations/hooks.js +49 -1
  39. package/dist/integrations/mergeDriver.d.ts +2 -0
  40. package/dist/integrations/mergeDriver.js +2 -2
  41. package/dist/integrations/providers.d.ts +7 -0
  42. package/dist/integrations/providers.js +6 -5
  43. package/dist/integrations/scaffold.d.ts +5 -0
  44. package/dist/integrations/scaffold.js +14 -8
  45. package/dist/integrations/uninstall.d.ts +40 -0
  46. package/dist/integrations/uninstall.js +435 -0
  47. package/dist/mcp/server.d.ts +2 -1
  48. package/dist/mcp/server.js +58 -4
  49. package/dist/mcp/taskReportTools.js +1 -1
  50. package/dist/serve/writelock.d.ts +7 -0
  51. package/dist/serve/writelock.js +30 -3
  52. package/dist/store/changeLedger.d.ts +5 -2
  53. package/dist/store/changeLedger.js +88 -37
  54. package/dist/store/hunchStore.js +4 -1
  55. package/package.json +3 -3
  56. package/server.json +2 -2
@@ -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;
package/dist/core/eol.js CHANGED
@@ -6,6 +6,12 @@ export function normalizeEol(text) {
6
6
  export function lineContent(line) {
7
7
  return line.endsWith("\r") ? line.slice(0, -1) : line;
8
8
  }
9
+ /** Length of the line break (CRLF, CR, or LF) starting at `at`, or 0. */
10
+ export function eolLengthAt(text, at) {
11
+ if (text[at] === "\r")
12
+ return text[at + 1] === "\n" ? 2 : 1;
13
+ return text[at] === "\n" ? 1 : 0;
14
+ }
9
15
  /** Read logical lines while keeping offsets in the original UTF-16 source. */
10
16
  export function* sourceLines(text) {
11
17
  let start = 0;
@@ -15,3 +15,25 @@ export declare function injectionMode(sessionId: string | undefined, key: string
15
15
  * reset, or post-compact edits get delta one-liners against grounding the
16
16
  * agent no longer has. Never throws (same posture as injectionMode). */
17
17
  export declare function resetSessionInjections(sessionId: string | undefined): void;
18
+ /** The machine-local directory every hook cache lives in (OS tmpdir). */
19
+ export declare function hookCacheDir(): string;
20
+ /** A task's prompt-time memory selection (taskSelection.ts): record ids only —
21
+ * never the prompt text it was scored from. Kill switch: HUNCH_TASK_SELECTION=0
22
+ * (no selection is written or read, so file grounding stays unfiltered). */
23
+ export interface TaskSelectionFile {
24
+ task_id: string;
25
+ qualifying: string[];
26
+ top: string[];
27
+ }
28
+ export declare function taskSelectionPath(taskId: string): string;
29
+ /** Persist a task's selection (atomic temp+rename). Throws; callers fail open. */
30
+ export declare function saveTaskSelection(selection: TaskSelectionFile): void;
31
+ export declare function taskSelectionEnabled(): boolean;
32
+ /** Remove a task's selection file (an empty selection is no selection). Never throws. */
33
+ export declare function clearTaskSelection(taskId: string): void;
34
+ /** The task's selection, or null when none was written (a host without a prompt
35
+ * hook, a legacy session), it holds no decision/bug/finding id (constraints
36
+ * are never filtered, so a constraint-only selection would only hide memory),
37
+ * it is unreadable, or selection is switched off — callers then keep today's
38
+ * unfiltered grounding. Never throws. */
39
+ export declare function loadTaskSelection(taskId: string | null | undefined): TaskSelectionFile | null;
@@ -18,7 +18,9 @@
18
18
  import { createHash } from "node:crypto";
19
19
  import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, rmSync } from "node:fs";
20
20
  import { join } from "node:path";
21
+ import { isFilterableSelectionId } from "./taskSelection.js";
21
22
  import { tmpdir } from "node:os";
23
+ import { writeFileAtomic } from "./io.js";
22
24
  const MAX_KEYS = 300;
23
25
  const SWEEP_AGE_MS = 48 * 3600 * 1000;
24
26
  /** Decide whether this injection should be the FULL grounding block or a delta
@@ -35,7 +37,7 @@ export function injectionMode(sessionId, key, content, hashInput = content) {
35
37
  try {
36
38
  if (!sessionId || process.env.HUNCH_HOOK_DEDUP === "0")
37
39
  return "full";
38
- const dir = join(tmpdir(), "hunch-hookcache");
40
+ const dir = hookCacheDir();
39
41
  mkdirSync(dir, { recursive: true });
40
42
  sweep(dir);
41
43
  const file = join(dir, `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
@@ -71,13 +73,62 @@ export function resetSessionInjections(sessionId) {
71
73
  try {
72
74
  if (!sessionId)
73
75
  return;
74
- const file = join(tmpdir(), "hunch-hookcache", `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
76
+ const file = join(hookCacheDir(), `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
75
77
  rmSync(file, { force: true });
76
78
  }
77
79
  catch {
78
80
  /* unwritable tmpdir — next injectionMode call falls back to "full" anyway */
79
81
  }
80
82
  }
83
+ /** The machine-local directory every hook cache lives in (OS tmpdir). */
84
+ export function hookCacheDir() {
85
+ return join(tmpdir(), "hunch-hookcache");
86
+ }
87
+ export function taskSelectionPath(taskId) {
88
+ return join(hookCacheDir(), `task-${taskId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
89
+ }
90
+ /** Persist a task's selection (atomic temp+rename). Throws; callers fail open. */
91
+ export function saveTaskSelection(selection) {
92
+ const dir = hookCacheDir();
93
+ mkdirSync(dir, { recursive: true });
94
+ sweep(dir);
95
+ writeFileAtomic(taskSelectionPath(selection.task_id), JSON.stringify({
96
+ task_id: selection.task_id, qualifying: [...selection.qualifying], top: [...selection.top],
97
+ }));
98
+ }
99
+ export function taskSelectionEnabled() {
100
+ return process.env.HUNCH_TASK_SELECTION !== "0";
101
+ }
102
+ /** Remove a task's selection file (an empty selection is no selection). Never throws. */
103
+ export function clearTaskSelection(taskId) {
104
+ try {
105
+ rmSync(taskSelectionPath(taskId), { force: true });
106
+ }
107
+ catch {
108
+ /* fail open: a stale file is read back as whatever it holds */
109
+ }
110
+ }
111
+ /** The task's selection, or null when none was written (a host without a prompt
112
+ * hook, a legacy session), it holds no decision/bug/finding id (constraints
113
+ * are never filtered, so a constraint-only selection would only hide memory),
114
+ * it is unreadable, or selection is switched off — callers then keep today's
115
+ * unfiltered grounding. Never throws. */
116
+ export function loadTaskSelection(taskId) {
117
+ try {
118
+ if (!taskId || !taskSelectionEnabled())
119
+ return null;
120
+ const raw = JSON.parse(readFileSync(taskSelectionPath(taskId), "utf8"));
121
+ if (!raw || raw.task_id !== taskId || !Array.isArray(raw.qualifying) || !Array.isArray(raw.top))
122
+ return null;
123
+ const qualifying = raw.qualifying.filter((id) => typeof id === "string");
124
+ if (!qualifying.some(isFilterableSelectionId))
125
+ return null;
126
+ return { task_id: taskId, qualifying, top: raw.top.filter((id) => typeof id === "string") };
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
81
132
  /** Drop session caches from long-gone sessions (best effort, bounded dir). */
82
133
  function sweep(dir) {
83
134
  try {
@@ -66,10 +66,14 @@ export declare function ownerInstance(ownerStart: string | undefined, pid: numbe
66
66
  * held nonce and could age-steal its live lock. */
67
67
  export declare const heldLockNonces: Set<string>;
68
68
  /** What a contender should do about a SAME-HOST lock owner.
69
- * - `stale` — provably not the owner's process any more: take it over.
70
- * - `live` — authoritative, however old the file looks: never steal it.
71
- * - `age` — nothing is proven; fall back to the caller's own age rule. */
72
- export type SameHostVerdict = "stale" | "live" | "age";
69
+ * - `stale` — provably not the owner's process any more: take it over.
70
+ * - `live` — authoritative, however old the file looks: never steal it.
71
+ * - `unproven` — a live pid whose process instance no token can compare (macOS,
72
+ * Windows, a legacy record). Treated as live; a caller with a
73
+ * better probe may still disprove it (src/serve/writelock.ts
74
+ * asks the OS when that process was created, issue #287).
75
+ * - `age` — nothing is proven; fall back to the caller's own age rule. */
76
+ export type SameHostVerdict = "stale" | "live" | "unproven" | "age";
73
77
  /** The parts of a lock record this decision reads. Both lock formats supply it. */
74
78
  export interface SameHostOwner {
75
79
  pid: number;
@@ -91,4 +95,11 @@ export interface SameHostEnv {
91
95
  * process state beyond what `env` hands it.
92
96
  */
93
97
  export declare function judgeSameHostOwner(owner: SameHostOwner, env: SameHostEnv): SameHostVerdict;
98
+ /** Wall-clock ms at which the process now holding `pid` was created, from the
99
+ * OS, or null when it cannot be read. SPAWNS (PowerShell on Windows, `ps` on
100
+ * macOS), so it is for rare paths only — a lock that already looks stale —
101
+ * never for every acquisition. Precision is a second on `ps`. Null on every
102
+ * other platform: linux derives `lstart` from boot time, which a forward clock
103
+ * step shifts for live processes too (see the header). */
104
+ export declare function processStartedAt(pid: number): number | null;
94
105
  export {};