@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.
- package/README.md +14 -0
- package/dist/cli/index.js +206 -37
- package/dist/core/changeProof.js +2 -1
- package/dist/core/commitrepair.d.ts +9 -3
- package/dist/core/commitrepair.js +23 -6
- package/dist/core/correction.d.ts +1 -1
- package/dist/core/correction.js +4 -3
- package/dist/core/delivery.d.ts +12 -0
- package/dist/core/delivery.js +4 -4
- package/dist/core/docanchors.d.ts +11 -2
- package/dist/core/docanchors.js +232 -65
- package/dist/core/drift.d.ts +1 -1
- package/dist/core/drift.js +10 -3
- package/dist/core/eol.d.ts +2 -0
- package/dist/core/eol.js +6 -0
- package/dist/core/hookcache.d.ts +22 -0
- package/dist/core/hookcache.js +53 -2
- package/dist/core/procstart.d.ts +15 -4
- package/dist/core/procstart.js +56 -18
- package/dist/core/publication.js +17 -3
- package/dist/core/runbookDraft.d.ts +7 -0
- package/dist/core/runbookDraft.js +43 -0
- package/dist/core/taskReportHook.js +1 -1
- package/dist/core/taskSelection.d.ts +67 -0
- package/dist/core/taskSelection.js +196 -0
- package/dist/core/taskTouched.d.ts +2 -0
- package/dist/core/taskTouched.js +1 -1
- package/dist/extractors/git.d.ts +36 -6
- package/dist/extractors/git.js +86 -9
- package/dist/extractors/indexer.js +53 -14
- package/dist/extractors/k8sManifest.d.ts +31 -1
- package/dist/extractors/k8sManifest.js +133 -8
- package/dist/integrations/claudemd.d.ts +2 -0
- package/dist/integrations/claudemd.js +4 -2
- package/dist/integrations/gitignore.d.ts +7 -0
- package/dist/integrations/gitignore.js +18 -0
- package/dist/integrations/hooks.d.ts +15 -0
- package/dist/integrations/hooks.js +49 -1
- package/dist/integrations/mergeDriver.d.ts +2 -0
- package/dist/integrations/mergeDriver.js +2 -2
- package/dist/integrations/providers.d.ts +7 -0
- package/dist/integrations/providers.js +6 -5
- package/dist/integrations/scaffold.d.ts +5 -0
- package/dist/integrations/scaffold.js +14 -8
- package/dist/integrations/uninstall.d.ts +40 -0
- package/dist/integrations/uninstall.js +435 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +58 -4
- package/dist/mcp/taskReportTools.js +1 -1
- package/dist/serve/writelock.d.ts +7 -0
- package/dist/serve/writelock.js +30 -3
- package/dist/store/changeLedger.d.ts +5 -2
- package/dist/store/changeLedger.js +88 -37
- package/dist/store/hunchStore.js +4 -1
- package/package.json +3 -3
- 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
|
-
*
|
|
26
|
-
*
|
|
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
|
package/dist/core/docanchors.js
CHANGED
|
@@ -58,14 +58,18 @@ function thematicBreakSuffixes(probe) {
|
|
|
58
58
|
}
|
|
59
59
|
return ok;
|
|
60
60
|
}
|
|
61
|
-
/** Character ranges covered by
|
|
62
|
-
* documentation EXAMPLE of a marker never registers as a live anchor.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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 =
|
|
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
|
-
|
|
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 <
|
|
210
|
+
while (i < rest.length && rest[i] === " " && i - base < 4)
|
|
145
211
|
i++;
|
|
146
|
-
|
|
147
|
-
if (
|
|
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 =
|
|
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 <
|
|
227
|
+
while (d < rest.length && rest[d] >= "0" && rest[d] <= "9" && d - i < 9)
|
|
160
228
|
d++;
|
|
161
|
-
if (
|
|
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 <
|
|
236
|
+
while (s < rest.length && rest[s] === " ")
|
|
169
237
|
s++;
|
|
170
238
|
const gap = s - (i + markerLen);
|
|
171
|
-
const emptyItem = s >=
|
|
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 ?
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
277
|
+
open = { kind: "fence", ch, len: m[1].length, start: lineStart, base, q, line: lineNo };
|
|
194
278
|
}
|
|
195
|
-
// A fence line
|
|
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
|
-
|
|
198
|
-
|
|
282
|
+
const setext = inParagraph && /^=+[ \t]*$/.test(content);
|
|
283
|
+
inParagraph = !m && !thematic && !setext && content.trim() !== "" && !/^#{1,6}(?:[ \t]|$)/.test(content);
|
|
199
284
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
233
|
-
*
|
|
388
|
+
* code blocks or inline code spans are examples, not declarations, and are
|
|
389
|
+
* skipped. */
|
|
234
390
|
export function parseDocAnchors(text) {
|
|
235
|
-
|
|
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
|
-
//
|
|
241
|
-
// and with the line numbers reported below.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
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
|
-
|
|
417
|
+
}
|
|
418
|
+
anchors.push({ topic: m[1], pin: m[2] ?? null, line: text.slice(0, at).split("\n").length });
|
|
252
419
|
}
|
|
253
|
-
return
|
|
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
|
package/dist/core/drift.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/drift.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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);
|
package/dist/core/eol.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/hookcache.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/hookcache.js
CHANGED
|
@@ -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 =
|
|
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(
|
|
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 {
|
package/dist/core/procstart.d.ts
CHANGED
|
@@ -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`
|
|
70
|
-
* - `live`
|
|
71
|
-
* - `
|
|
72
|
-
|
|
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 {};
|