@davesheffer/hunch 1.39.2 → 1.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +154 -64
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- package/dist/constitution/g2BehaviorCandidates.js +6 -1
- package/dist/constitution/g2Candidates.js +11 -1
- package/dist/constitution/structural.js +10 -0
- package/dist/core/correction.js +12 -7
- package/dist/core/delivery.d.ts +34 -0
- package/dist/core/delivery.js +60 -1
- package/dist/core/docanchors.js +195 -11
- package/dist/core/format.js +6 -1
- package/dist/core/glob.d.ts +25 -0
- package/dist/core/glob.js +43 -0
- package/dist/core/hookcache.d.ts +9 -2
- package/dist/core/hookcache.js +10 -3
- package/dist/core/paths.d.ts +48 -5
- package/dist/core/paths.js +89 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- package/dist/core/taskDelivery.js +27 -1
- package/dist/core/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +17 -1
- package/dist/core/taskReportHook.d.ts +19 -0
- package/dist/core/taskReportHook.js +54 -6
- package/dist/core/verifyLauncher.d.ts +21 -0
- package/dist/core/verifyLauncher.js +37 -0
- package/dist/core/workspace.d.ts +9 -0
- package/dist/core/workspace.js +15 -0
- package/dist/extractors/git.d.ts +5 -0
- package/dist/extractors/git.js +87 -10
- package/dist/extractors/indexer.d.ts +10 -0
- package/dist/extractors/indexer.js +56 -10
- package/dist/extractors/k8sManifest.d.ts +13 -0
- package/dist/extractors/k8sManifest.js +207 -23
- package/dist/extractors/landscapeDiscovery.js +9 -1
- package/dist/extractors/nativeTreeSitter.d.ts +24 -0
- package/dist/extractors/nativeTreeSitter.js +54 -1
- package/dist/extractors/parse.js +6 -1
- package/dist/integrations/claudemd.js +3 -3
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +79 -9
- package/dist/integrations/hooks.js +559 -46
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +24 -21
- package/dist/integrations/workspaceLedger.d.ts +10 -0
- package/dist/integrations/workspaceLedger.js +9 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +32 -16
- package/dist/mcp/taskReportTools.d.ts +5 -9
- package/dist/mcp/taskReportTools.js +9 -20
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/changeLedger.d.ts +9 -3
- package/dist/store/changeLedger.js +36 -10
- package/dist/store/hunchStore.d.ts +42 -2
- package/dist/store/hunchStore.js +105 -17
- package/dist/store/jsonStore.d.ts +6 -4
- package/dist/store/jsonStore.js +225 -32
- package/dist/store/merge.d.ts +7 -4
- package/dist/store/merge.js +65 -5
- package/dist/store/privateMigrate.js +10 -0
- package/dist/wiki/adopt.js +13 -2
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/core/delivery.d.ts
CHANGED
|
@@ -28,6 +28,14 @@ export interface DeliverySupplement {
|
|
|
28
28
|
text: string;
|
|
29
29
|
/** Higher values are attempted first after ranked memory. */
|
|
30
30
|
priority?: number;
|
|
31
|
+
/** Stable IDENTITY of the records behind `text`, for callers that dedup
|
|
32
|
+
* injections by hash (the pre-edit hook). Some supplements are
|
|
33
|
+
* self-invalidating: serving them writes delivery receipts, and the next
|
|
34
|
+
* render's wording, scores or slot order move with no record change. Set this
|
|
35
|
+
* to what is a property of the record — never of delivery state or the clock —
|
|
36
|
+
* and `deliveryDedupeInput` swaps it in for `text` when hashing. Omitted →
|
|
37
|
+
* `text` is its own identity. Never shown to the agent. */
|
|
38
|
+
hash_text?: string;
|
|
31
39
|
}
|
|
32
40
|
export interface DeliveredSupplement {
|
|
33
41
|
id: string;
|
|
@@ -106,5 +114,31 @@ export interface DeliveryOptions {
|
|
|
106
114
|
}
|
|
107
115
|
/** Validate the public receipt without trusting a caller-supplied identity. */
|
|
108
116
|
export declare function assertDeliveryEnvelope(envelope: DeliveryEnvelope): void;
|
|
117
|
+
/** The string a hash-based injection dedup (`injectionMode`'s `hashInput`) must
|
|
118
|
+
* key on instead of the rendered block: a STABLE IDENTITY PROJECTION of this
|
|
119
|
+
* envelope.
|
|
120
|
+
*
|
|
121
|
+
* THE RULE, for every caller and every provider: nothing that depends on
|
|
122
|
+
* delivery receipts, ranking warmth or scores, slot labels, line ORDER, or
|
|
123
|
+
* `now` may enter the dedupe hash; a record entering, leaving, or changing
|
|
124
|
+
* content MUST. Grounding that violates it is self-invalidating — serving the
|
|
125
|
+
* block writes delivery receipts, the next call's ranking reads them back, the
|
|
126
|
+
* wording moves ("today" → "delivered today"), the hash changes, and the full
|
|
127
|
+
* block is re-sent for records the agent already has.
|
|
128
|
+
*
|
|
129
|
+
* The projection, done line-wise over the one text the assembler produced:
|
|
130
|
+
* - a supplement that declared `hash_text` contributes that identity (kind +
|
|
131
|
+
* record id + the record's own content hash) instead of its rendered line —
|
|
132
|
+
* that is where reason text, slot labels and age words live;
|
|
133
|
+
* - every other line contributes verbatim, because it is already a property of
|
|
134
|
+
* the records (id, statement, scope, provenance tier, validation state);
|
|
135
|
+
* - each contiguous run of `- ` list lines is SORTED, so a pure re-ordering of
|
|
136
|
+
* the same records is not a change while a record entering or leaving the run
|
|
137
|
+
* still is.
|
|
138
|
+
*
|
|
139
|
+
* Nothing is removed from what the agent SEES — this string is never emitted.
|
|
140
|
+
* Never throws: on any surprise the caller gets `envelope.text` back, i.e. the
|
|
141
|
+
* original hash-the-presentation behaviour (a full block, never a wrong delta). */
|
|
142
|
+
export declare function deliveryDedupeInput(envelope: DeliveryEnvelope, supplements?: readonly DeliverySupplement[]): string;
|
|
109
143
|
/** Build the one envelope used by CLI, MCP, and the edit hook. */
|
|
110
144
|
export declare function buildDeliveryEnvelope(ctx: AssembledContext, options?: DeliveryOptions): DeliveryEnvelope;
|
package/dist/core/delivery.js
CHANGED
|
@@ -21,6 +21,9 @@ const MIN_ADVISORY_CONFIDENCE = 0.5;
|
|
|
21
21
|
const MIN_UNCONDITIONED_CONFIDENCE = 0.7;
|
|
22
22
|
const MAX_ACTIONABLE_HYPOTHESES = 2;
|
|
23
23
|
const MAX_PROFILE_HEADLINES = 8;
|
|
24
|
+
/** How far a supplement's text is clipped in the rendered line. Shared so
|
|
25
|
+
* `deliveryDedupeInput` can reconstruct that exact line to project over it. */
|
|
26
|
+
const SUPPLEMENT_HEADLINE_CHARS = 700;
|
|
24
27
|
const PROFILE_BASE_SCORE = {
|
|
25
28
|
builder: {
|
|
26
29
|
constraints: 900,
|
|
@@ -457,6 +460,62 @@ export function assertDeliveryEnvelope(envelope) {
|
|
|
457
460
|
throw new Error("delivery envelope receipt does not match its content");
|
|
458
461
|
}
|
|
459
462
|
}
|
|
463
|
+
/** The string a hash-based injection dedup (`injectionMode`'s `hashInput`) must
|
|
464
|
+
* key on instead of the rendered block: a STABLE IDENTITY PROJECTION of this
|
|
465
|
+
* envelope.
|
|
466
|
+
*
|
|
467
|
+
* THE RULE, for every caller and every provider: nothing that depends on
|
|
468
|
+
* delivery receipts, ranking warmth or scores, slot labels, line ORDER, or
|
|
469
|
+
* `now` may enter the dedupe hash; a record entering, leaving, or changing
|
|
470
|
+
* content MUST. Grounding that violates it is self-invalidating — serving the
|
|
471
|
+
* block writes delivery receipts, the next call's ranking reads them back, the
|
|
472
|
+
* wording moves ("today" → "delivered today"), the hash changes, and the full
|
|
473
|
+
* block is re-sent for records the agent already has.
|
|
474
|
+
*
|
|
475
|
+
* The projection, done line-wise over the one text the assembler produced:
|
|
476
|
+
* - a supplement that declared `hash_text` contributes that identity (kind +
|
|
477
|
+
* record id + the record's own content hash) instead of its rendered line —
|
|
478
|
+
* that is where reason text, slot labels and age words live;
|
|
479
|
+
* - every other line contributes verbatim, because it is already a property of
|
|
480
|
+
* the records (id, statement, scope, provenance tier, validation state);
|
|
481
|
+
* - each contiguous run of `- ` list lines is SORTED, so a pure re-ordering of
|
|
482
|
+
* the same records is not a change while a record entering or leaving the run
|
|
483
|
+
* still is.
|
|
484
|
+
*
|
|
485
|
+
* Nothing is removed from what the agent SEES — this string is never emitted.
|
|
486
|
+
* Never throws: on any surprise the caller gets `envelope.text` back, i.e. the
|
|
487
|
+
* original hash-the-presentation behaviour (a full block, never a wrong delta). */
|
|
488
|
+
export function deliveryDedupeInput(envelope, supplements = []) {
|
|
489
|
+
try {
|
|
490
|
+
const identity = new Map();
|
|
491
|
+
for (const s of supplements) {
|
|
492
|
+
if (s.hash_text === undefined)
|
|
493
|
+
continue;
|
|
494
|
+
// Key on the line exactly as the assembler rendered it, so the swap is a
|
|
495
|
+
// substitution and never a guess about the text's shape.
|
|
496
|
+
identity.set(`- supplemental/${s.kind} | ${clipHeadline(s.text, SUPPLEMENT_HEADLINE_CHARS)}`, `- supplemental/${s.kind} | identity\u0000${s.hash_text}`);
|
|
497
|
+
}
|
|
498
|
+
const projected = [];
|
|
499
|
+
let run = [];
|
|
500
|
+
const flush = () => { if (run.length) {
|
|
501
|
+
projected.push(...run.sort());
|
|
502
|
+
run = [];
|
|
503
|
+
} };
|
|
504
|
+
for (const line of envelope.text.split("\n")) {
|
|
505
|
+
if (!line.startsWith("- ")) {
|
|
506
|
+
flush();
|
|
507
|
+
projected.push(line);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
run.push(identity.get(line) ?? line);
|
|
511
|
+
}
|
|
512
|
+
flush();
|
|
513
|
+
return projected.join("\n");
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
return envelope.text; // the projection is an optimization, never a correctness dependency
|
|
517
|
+
}
|
|
518
|
+
}
|
|
460
519
|
/** Build the one envelope used by CLI, MCP, and the edit hook. */
|
|
461
520
|
export function buildDeliveryEnvelope(ctx, options = {}) {
|
|
462
521
|
const profile = options.profile ?? "builder";
|
|
@@ -742,7 +801,7 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
|
|
|
742
801
|
...(options.supplements ?? []),
|
|
743
802
|
].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.id.localeCompare(right.id));
|
|
744
803
|
for (const [index, supplement] of supplementalCandidates.entries()) {
|
|
745
|
-
const content = clipHeadline(supplement.text,
|
|
804
|
+
const content = clipHeadline(supplement.text, SUPPLEMENT_HEADLINE_CHARS);
|
|
746
805
|
if (!content) {
|
|
747
806
|
supplements.push({ id: supplement.id, kind: supplement.kind, delivered: false, reason: "empty", rank: index + 1, token_cost: 0 });
|
|
748
807
|
continue;
|
package/dist/core/docanchors.js
CHANGED
|
@@ -1,27 +1,199 @@
|
|
|
1
1
|
import { currentForTopic, rejectedForTopic } from "./topics.js";
|
|
2
2
|
const MARKER = /<!--\s*hunch:topic\s+([A-Za-z0-9._/-]+)(?:\s+(dec_[A-Za-z0-9]+))?\s*-->/g;
|
|
3
|
+
/** Expand the leading whitespace of a line to columns. A tab advances to the
|
|
4
|
+
* NEXT 4-column tab stop (CommonMark), not a flat 4 columns: after two
|
|
5
|
+
* spaces a tab is worth 2, so ` \t``` ` sits at column 4 like `\t``` `.
|
|
6
|
+
* Matching-only — offsets always come from the original line. */
|
|
7
|
+
function expandTabs(line) {
|
|
8
|
+
const ws = /^[ \t]*/.exec(line)[0];
|
|
9
|
+
if (!ws.includes("\t"))
|
|
10
|
+
return line;
|
|
11
|
+
let col = 0;
|
|
12
|
+
for (const c of ws)
|
|
13
|
+
col += c === "\t" ? 4 - (col % 4) : 1;
|
|
14
|
+
return " ".repeat(col) + line.slice(ws.length);
|
|
15
|
+
}
|
|
16
|
+
/** For every index i of `probe`, whether probe.slice(i) is a thematic break
|
|
17
|
+
* (`---`, `* * *`, `___`: ≤3 leading spaces, then ≥3 of one char separated by
|
|
18
|
+
* spaces only). Computed right-to-left in ONE pass so the list-marker walk
|
|
19
|
+
* below can ask the question at each nesting level without re-scanning the
|
|
20
|
+
* rest of the line each time — the difference between O(L) and O(L²) on a
|
|
21
|
+
* line that is nothing but list markers (`- - - …`, an adversarial doc). */
|
|
22
|
+
function thematicBreakSuffixes(probe) {
|
|
23
|
+
const L = probe.length;
|
|
24
|
+
// runFrom[i] = the break-so-far state of probe.slice(i) when it consists only
|
|
25
|
+
// of spaces and one repeated break char: its char, and how many were seen.
|
|
26
|
+
const ok = new Array(L + 1).fill(false);
|
|
27
|
+
let ch = "";
|
|
28
|
+
let count = 0;
|
|
29
|
+
let onlySpaceSoFar = true;
|
|
30
|
+
for (let i = L - 1; i >= 0; i--) {
|
|
31
|
+
const c = probe[i];
|
|
32
|
+
if (c === " " || c === "\t") {
|
|
33
|
+
// A space never breaks the run; it is allowed anywhere, including the
|
|
34
|
+
// ≤3 leading columns handled by the indent check at the call site.
|
|
35
|
+
ok[i] = ok[i + 1];
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (c === "-" || c === "*" || c === "_") {
|
|
39
|
+
if (onlySpaceSoFar) {
|
|
40
|
+
ch = c;
|
|
41
|
+
count = 1;
|
|
42
|
+
onlySpaceSoFar = false;
|
|
43
|
+
}
|
|
44
|
+
else if (c === ch)
|
|
45
|
+
count++;
|
|
46
|
+
// Two different break chars: every suffix starting at or before i now
|
|
47
|
+
// mixes them (`- - - * -` is five nested items, not a break), so stop.
|
|
48
|
+
else
|
|
49
|
+
return ok;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Any other char means no suffix starting at or before i is a break;
|
|
53
|
+
// ok is already false there, so stop.
|
|
54
|
+
return ok;
|
|
55
|
+
}
|
|
56
|
+
ok[i] = count >= 3;
|
|
57
|
+
}
|
|
58
|
+
return ok;
|
|
59
|
+
}
|
|
3
60
|
/** Character ranges covered by fenced code blocks (``` or ~~~), so a
|
|
4
61
|
* documentation EXAMPLE of a marker never registers as a live anchor.
|
|
5
62
|
* CommonMark-lite: a fence of N chars (≤3 leading spaces) closes only on a
|
|
6
63
|
* line of ≥N of the same char and nothing else; an unclosed fence runs to
|
|
7
|
-
* EOF; a backtick fence's info string may not itself contain a backtick.
|
|
64
|
+
* EOF; a backtick fence's info string may not itself contain a backtick.
|
|
65
|
+
* The "≤3 leading spaces" is measured relative to the enclosing LIST ITEM's
|
|
66
|
+
* content offset, so a fence indented under `1. step` (issue #331) is still a
|
|
67
|
+
* fence and not an indented code block.
|
|
68
|
+
*
|
|
69
|
+
* A fence hosted in a list item ends where the ITEM ends: at the first
|
|
70
|
+
* non-blank line dedented below the item's content base. That is what the
|
|
71
|
+
* rendered document shows — the closing ``` of a sloppily indented example
|
|
72
|
+
* sits outside the item, so it terminates the item's fence and opens a new
|
|
73
|
+
* top-level one — and this scanner's job is to agree with the rendering a
|
|
74
|
+
* reader sees, not with the author's intent. In sloppy docs that differs from
|
|
75
|
+
* a flat fence scan; the rendering is the tiebreaker.
|
|
76
|
+
*
|
|
77
|
+
* Tabs expand to the next 4-column tab stop (not a flat 4), so `\t``` ` sits
|
|
78
|
+
* at column 4 exactly as a renderer places it. Offsets always come from the
|
|
79
|
+
* ORIGINAL line, never from the expanded probe.
|
|
80
|
+
*
|
|
81
|
+
* Deliberate limits: no lazy continuations and no blockquote containers — a
|
|
82
|
+
* `>` prefix is still read as ordinary text. With no list open the item stack
|
|
83
|
+
* is empty, the base is 0 and behaviour is the plain CommonMark-lite one.
|
|
84
|
+
* Expects LF-normalized text — see parseDocAnchors's normalization; a caller
|
|
85
|
+
* that skips it re-opens the CRLF fence-detection bug. */
|
|
8
86
|
function fencedRanges(text) {
|
|
87
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
9
88
|
const ranges = [];
|
|
10
89
|
let open = null;
|
|
90
|
+
// Content offsets (columns) of the currently open list items, outermost first.
|
|
91
|
+
const items = [];
|
|
11
92
|
let offset = 0;
|
|
93
|
+
// Whether the previous line was a paragraph-continuation line: non-blank,
|
|
94
|
+
// and not itself a container/leaf opener. Only a bullet or a `1.`/`1)` with
|
|
95
|
+
// non-empty content may interrupt such a paragraph (CommonMark), so prose
|
|
96
|
+
// like "see item\n2. the second point" stays one paragraph.
|
|
97
|
+
let inParagraph = false;
|
|
12
98
|
for (const line of text.split("\n")) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
ranges.push([open.start, offset + line.length]);
|
|
99
|
+
// Matching-only copy: tabs advance to the next 4-column tab stop.
|
|
100
|
+
const probe = expandTabs(line);
|
|
101
|
+
const indent = probe.length - probe.replace(/^ +/, "").length;
|
|
102
|
+
const blank = probe.trim() === "";
|
|
103
|
+
if (open) {
|
|
104
|
+
if (!blank && indent < open.base) {
|
|
105
|
+
// The list item holding the fence ended, which ends the fence too.
|
|
106
|
+
ranges.push([open.start, offset - 1]);
|
|
22
107
|
open = null;
|
|
23
108
|
}
|
|
109
|
+
else {
|
|
110
|
+
const m = FENCE.exec(probe.slice(Math.min(open.base, indent)));
|
|
111
|
+
if (m && m[1][0] === open.ch && m[1].length >= open.len && m[2].trim() === "") {
|
|
112
|
+
ranges.push([open.start, offset + line.length]);
|
|
113
|
+
open = null;
|
|
114
|
+
}
|
|
115
|
+
offset += line.length + 1;
|
|
116
|
+
continue; // the item stack is frozen while a fence is open
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (blank) {
|
|
120
|
+
offset += line.length + 1;
|
|
121
|
+
inParagraph = false;
|
|
122
|
+
continue; // a blank line neither opens nor closes an item here
|
|
123
|
+
}
|
|
124
|
+
// A dedent that closes an item also closes the paragraph inside it, so the
|
|
125
|
+
// line is a fresh block start: `1. one\n2. two` is two sibling items, not
|
|
126
|
+
// item one's paragraph being "interrupted" by a `2.`.
|
|
127
|
+
let popped = false;
|
|
128
|
+
while (items.length && items.at(-1) > indent) {
|
|
129
|
+
items.pop();
|
|
130
|
+
popped = true;
|
|
131
|
+
}
|
|
132
|
+
const isBreak = thematicBreakSuffixes(probe);
|
|
133
|
+
// Walk the line with an INDEX, peeling one list marker per step. Each step
|
|
134
|
+
// is O(marker) and the cheap checks are O(1), so a line costs O(length)
|
|
135
|
+
// however many markers it carries.
|
|
136
|
+
let base = items.at(-1) ?? 0;
|
|
137
|
+
let opened = false;
|
|
138
|
+
let thematic = false;
|
|
139
|
+
for (;;) {
|
|
140
|
+
// Leading spaces of this nesting level: ≤3, else it is indented content
|
|
141
|
+
// (and ` ---` is paragraph text or code, never a thematic break).
|
|
142
|
+
let i = base;
|
|
143
|
+
while (i < probe.length && probe[i] === " " && i - base < 4)
|
|
144
|
+
i++;
|
|
145
|
+
const lead = i - base;
|
|
146
|
+
if (lead > 3)
|
|
147
|
+
break;
|
|
148
|
+
if (isBreak[i]) {
|
|
149
|
+
thematic = true;
|
|
150
|
+
break;
|
|
151
|
+
} // thematic break, not a list marker
|
|
152
|
+
const c = probe[i];
|
|
153
|
+
let markerLen = 0;
|
|
154
|
+
if (c === "-" || c === "*" || c === "+")
|
|
155
|
+
markerLen = 1;
|
|
156
|
+
else if (c !== undefined && c >= "0" && c <= "9") {
|
|
157
|
+
let d = i;
|
|
158
|
+
while (d < probe.length && probe[d] >= "0" && probe[d] <= "9" && d - i < 9)
|
|
159
|
+
d++;
|
|
160
|
+
if (probe[d] === "." || probe[d] === ")")
|
|
161
|
+
markerLen = d - i + 1;
|
|
162
|
+
}
|
|
163
|
+
if (!markerLen)
|
|
164
|
+
break;
|
|
165
|
+
// Spaces between the marker and the item's content.
|
|
166
|
+
let s = i + markerLen;
|
|
167
|
+
while (s < probe.length && probe[s] === " ")
|
|
168
|
+
s++;
|
|
169
|
+
const gap = s - (i + markerLen);
|
|
170
|
+
const emptyItem = s >= probe.length;
|
|
171
|
+
if (!emptyItem && gap === 0)
|
|
172
|
+
break; // `-foo` / `1.foo` is not a marker
|
|
173
|
+
if (!opened && !popped && inParagraph && items.length === 0) {
|
|
174
|
+
// Interrupting a paragraph: only a bullet, or `1.`/`1)`, and never with
|
|
175
|
+
// empty content. `2. the second point` mid-prose stays paragraph text.
|
|
176
|
+
const ordinal = markerLen > 1 ? probe.slice(i, i + markerLen - 1) : "";
|
|
177
|
+
if (emptyItem || (ordinal !== "" && ordinal !== "1"))
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
// ≥5 spaces after the marker starts an indented code block, so the
|
|
181
|
+
// item's content begins one column after the marker instead.
|
|
182
|
+
const w = gap >= 1 && gap <= 4 ? gap : 1;
|
|
183
|
+
base = base + lead + markerLen + w;
|
|
184
|
+
items.push(base);
|
|
185
|
+
opened = true;
|
|
186
|
+
continue; // `- 1. x` nests, and "- ```js" opens a fence on the marker line
|
|
187
|
+
}
|
|
188
|
+
const m = FENCE.exec(probe.slice(base));
|
|
189
|
+
if (m) {
|
|
190
|
+
const ch = m[1][0];
|
|
191
|
+
if (!(ch === "`" && m[2].includes("`")))
|
|
192
|
+
open = { ch, len: m[1].length, start: offset, base };
|
|
24
193
|
}
|
|
194
|
+
// A fence line or a thematic break ends the paragraph it follows; ordinary
|
|
195
|
+
// text (including an item's own content) continues or starts one.
|
|
196
|
+
inParagraph = !m && !thematic;
|
|
25
197
|
offset += line.length + 1;
|
|
26
198
|
}
|
|
27
199
|
if (open)
|
|
@@ -31,7 +203,11 @@ function fencedRanges(text) {
|
|
|
31
203
|
/** Character ranges covered by inline code spans (`…`), same rationale as
|
|
32
204
|
* fencedRanges: prose quoting a marker in backticks is showing an example.
|
|
33
205
|
* CommonMark-lite: an opener run pairs with the next run of the SAME length
|
|
34
|
-
* on the same line; unpaired runs never open a span.
|
|
206
|
+
* on the same line; unpaired runs never open a span.
|
|
207
|
+
* Expects LF-normalized text — see parseDocAnchors's normalization.
|
|
208
|
+
* `split("\n")` does not split a bare CR, so on CR-only input the whole
|
|
209
|
+
* document reads as one line and stray backticks on different lines
|
|
210
|
+
* falsely pair. */
|
|
35
211
|
function inlineSpanRanges(text) {
|
|
36
212
|
const ranges = [];
|
|
37
213
|
let offset = 0;
|
|
@@ -55,6 +231,14 @@ function inlineSpanRanges(text) {
|
|
|
55
231
|
* fenced code blocks or inline code spans are examples, not declarations,
|
|
56
232
|
* and are skipped. */
|
|
57
233
|
export function parseDocAnchors(text) {
|
|
234
|
+
// fencedRanges is CRLF-sensitive (its fence-line regex's `.` excludes \r, so
|
|
235
|
+
// "```\r" never matched at all on a CRLF checkout); inlineSpanRanges is
|
|
236
|
+
// lone-CR-sensitive (split("\n") doesn't split a bare CR — see its
|
|
237
|
+
// docblock). Either way an example marker inside a fence/span registered as
|
|
238
|
+
// a live, pinned anchor. Normalizing both CRLF and lone CR once here keeps
|
|
239
|
+
// fencedRanges/inlineSpanRanges/MARKER offsets consistent with each other
|
|
240
|
+
// and with the line numbers reported below.
|
|
241
|
+
text = text.replace(/\r\n?/g, "\n");
|
|
58
242
|
const out = [];
|
|
59
243
|
const skip = [...fencedRanges(text), ...inlineSpanRanges(text)];
|
|
60
244
|
MARKER.lastIndex = 0;
|
package/dist/core/format.js
CHANGED
|
@@ -16,8 +16,13 @@ export function formatSearchHit(hit, record) {
|
|
|
16
16
|
/** Render a StructureView as a compact orientation brief (hunch_structure). */
|
|
17
17
|
export function formatStructure(v) {
|
|
18
18
|
const NL = "\n";
|
|
19
|
-
if (v.kind === "none")
|
|
19
|
+
if (v.kind === "none") {
|
|
20
|
+
// A real working-tree file with zero indexed symbols is a different answer from
|
|
21
|
+
// an unknown path — never tell the user a file we just stat'd doesn't exist.
|
|
22
|
+
if (v.realFile)
|
|
23
|
+
return `"${v.target}" is a real file, but the index holds no symbols for it — nothing to outline. Run hunch index if the repo changed, or hunch_why for its recorded decisions and invariants.`;
|
|
20
24
|
return `Nothing indexed matches "${v.target}" — not a known file, directory, or symbol. Run hunch index if the repo changed, or hunch_query for fuzzy search.`;
|
|
25
|
+
}
|
|
21
26
|
if (v.kind === "repo") {
|
|
22
27
|
const out = [`# Repo structure (from the graph — no grep needed)`];
|
|
23
28
|
if (v.components.length) {
|
package/dist/core/glob.d.ts
CHANGED
|
@@ -7,3 +7,28 @@ export declare function pathMatchesGlob(path: string, glob: string): boolean;
|
|
|
7
7
|
/** Do two concrete repo paths identify the same path at different prefix depth?
|
|
8
8
|
* Segment-anchored: `x/scenario.ts` relates to `scenario.ts`, never `io.ts`. */
|
|
9
9
|
export declare function pathsRelated(left: string, right: string): boolean;
|
|
10
|
+
/** True when `target` is a path Hunch's index already knows about: an indexed
|
|
11
|
+
* symbol's exact file, or a path an indexed component's `paths` glob(s) cover.
|
|
12
|
+
* The latter catches a symbol-less-but-indexed file (README.md, package.json,
|
|
13
|
+
* Dockerfile, ...) that has zero tree-sitter symbols but is still a real,
|
|
14
|
+
* known file. Derived entirely from already-loaded graph data — never the
|
|
15
|
+
* filesystem — so the answer doesn't depend on untracked working-tree state
|
|
16
|
+
* (a deleted-but-still-indexed path stays "real"; issue #299). It is therefore
|
|
17
|
+
* only HALF the "is this a real path" question: a real file with no symbols and
|
|
18
|
+
* no covering component is invisible here, so callers OR in `isRepoFile` as a
|
|
19
|
+
* last resort (issue #334) — `HunchStore.isKnownPath` is that composition. */
|
|
20
|
+
export declare function isIndexedPath(target: string, symbolFiles: Iterable<string>, componentPaths: Iterable<readonly string[]>): boolean;
|
|
21
|
+
/** Resolve symbols matching `target`, tiered: exact id > exact name > exact file >
|
|
22
|
+
* (only when `target` is NOT a path already known to be real) segment-anchored
|
|
23
|
+
* suffix. A real file with zero symbols must return [] rather than fall through
|
|
24
|
+
* to the suffix tier, which would leak an unrelated same-basename file's records
|
|
25
|
+
* (issues #299/#334). Callers decide what counts as "real": one that ATTRIBUTES
|
|
26
|
+
* records silently (why(), the pre-edit hook) passes `HunchStore.isKnownPath`, the
|
|
27
|
+
* wider graph-or-working-tree answer that also covers a file about to be created;
|
|
28
|
+
* one that NAMES the file it resolved to (resolveNodeIds, structure()) passes the
|
|
29
|
+
* narrower `isRepoFile`, since glob coverage alone is not existence. */
|
|
30
|
+
export declare function matchSymbolsTiered<S extends {
|
|
31
|
+
id: string;
|
|
32
|
+
file: string;
|
|
33
|
+
name: string;
|
|
34
|
+
}>(target: string, symbols: readonly S[], indexed: boolean): S[];
|
package/dist/core/glob.js
CHANGED
|
@@ -66,4 +66,47 @@ export function pathsRelated(left, right) {
|
|
|
66
66
|
const b = norm(right);
|
|
67
67
|
return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`);
|
|
68
68
|
}
|
|
69
|
+
/** True when `target` is a path Hunch's index already knows about: an indexed
|
|
70
|
+
* symbol's exact file, or a path an indexed component's `paths` glob(s) cover.
|
|
71
|
+
* The latter catches a symbol-less-but-indexed file (README.md, package.json,
|
|
72
|
+
* Dockerfile, ...) that has zero tree-sitter symbols but is still a real,
|
|
73
|
+
* known file. Derived entirely from already-loaded graph data — never the
|
|
74
|
+
* filesystem — so the answer doesn't depend on untracked working-tree state
|
|
75
|
+
* (a deleted-but-still-indexed path stays "real"; issue #299). It is therefore
|
|
76
|
+
* only HALF the "is this a real path" question: a real file with no symbols and
|
|
77
|
+
* no covering component is invisible here, so callers OR in `isRepoFile` as a
|
|
78
|
+
* last resort (issue #334) — `HunchStore.isKnownPath` is that composition. */
|
|
79
|
+
export function isIndexedPath(target, symbolFiles, componentPaths) {
|
|
80
|
+
for (const f of symbolFiles)
|
|
81
|
+
if (f === target)
|
|
82
|
+
return true;
|
|
83
|
+
for (const globs of componentPaths)
|
|
84
|
+
for (const g of globs)
|
|
85
|
+
if (pathMatchesGlob(target, g))
|
|
86
|
+
return true;
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
/** Resolve symbols matching `target`, tiered: exact id > exact name > exact file >
|
|
90
|
+
* (only when `target` is NOT a path already known to be real) segment-anchored
|
|
91
|
+
* suffix. A real file with zero symbols must return [] rather than fall through
|
|
92
|
+
* to the suffix tier, which would leak an unrelated same-basename file's records
|
|
93
|
+
* (issues #299/#334). Callers decide what counts as "real": one that ATTRIBUTES
|
|
94
|
+
* records silently (why(), the pre-edit hook) passes `HunchStore.isKnownPath`, the
|
|
95
|
+
* wider graph-or-working-tree answer that also covers a file about to be created;
|
|
96
|
+
* one that NAMES the file it resolved to (resolveNodeIds, structure()) passes the
|
|
97
|
+
* narrower `isRepoFile`, since glob coverage alone is not existence. */
|
|
98
|
+
export function matchSymbolsTiered(target, symbols, indexed) {
|
|
99
|
+
const byId = symbols.find((s) => s.id === target);
|
|
100
|
+
if (byId)
|
|
101
|
+
return [byId];
|
|
102
|
+
const byName = symbols.filter((s) => s.name === target);
|
|
103
|
+
if (byName.length)
|
|
104
|
+
return byName;
|
|
105
|
+
const exact = symbols.filter((s) => s.file === target);
|
|
106
|
+
if (exact.length)
|
|
107
|
+
return exact;
|
|
108
|
+
if (indexed)
|
|
109
|
+
return [];
|
|
110
|
+
return symbols.filter((s) => pathsRelated(s.file, target));
|
|
111
|
+
}
|
|
69
112
|
//# sourceMappingURL=glob.js.map
|
package/dist/core/hookcache.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
/** Decide whether this injection should be the FULL grounding block or a delta
|
|
2
2
|
* one-liner. Records the content hash as a side effect (so the next identical
|
|
3
|
-
* call dedups). Never throws.
|
|
4
|
-
|
|
3
|
+
* call dedups). Never throws.
|
|
4
|
+
*
|
|
5
|
+
* `hashInput` lets a caller dedup on a STABLE PROJECTION of the block instead
|
|
6
|
+
* of its presentation: some grounding is self-invalidating —
|
|
7
|
+
* serving it writes delivery receipts, and the next call's wording moves
|
|
8
|
+
* ("today" → "delivered today") with no record change, so hashing the rendered
|
|
9
|
+
* text re-sends the full block forever. Callers pass the identity of the
|
|
10
|
+
* underlying records; omitting it hashes `content`, the original contract. */
|
|
11
|
+
export declare function injectionMode(sessionId: string | undefined, key: string, content: string, hashInput?: string): "full" | "delta";
|
|
5
12
|
/** Forget everything injected into a session. Compaction summarizes injected
|
|
6
13
|
* grounding out of the agent's context while the dedup map still says
|
|
7
14
|
* "delivered" — so on PreCompact / SessionStart[source=compact] the map must
|
package/dist/core/hookcache.js
CHANGED
|
@@ -23,8 +23,15 @@ const MAX_KEYS = 300;
|
|
|
23
23
|
const SWEEP_AGE_MS = 48 * 3600 * 1000;
|
|
24
24
|
/** Decide whether this injection should be the FULL grounding block or a delta
|
|
25
25
|
* one-liner. Records the content hash as a side effect (so the next identical
|
|
26
|
-
* call dedups). Never throws.
|
|
27
|
-
|
|
26
|
+
* call dedups). Never throws.
|
|
27
|
+
*
|
|
28
|
+
* `hashInput` lets a caller dedup on a STABLE PROJECTION of the block instead
|
|
29
|
+
* of its presentation: some grounding is self-invalidating —
|
|
30
|
+
* serving it writes delivery receipts, and the next call's wording moves
|
|
31
|
+
* ("today" → "delivered today") with no record change, so hashing the rendered
|
|
32
|
+
* text re-sends the full block forever. Callers pass the identity of the
|
|
33
|
+
* underlying records; omitting it hashes `content`, the original contract. */
|
|
34
|
+
export function injectionMode(sessionId, key, content, hashInput = content) {
|
|
28
35
|
try {
|
|
29
36
|
if (!sessionId || process.env.HUNCH_HOOK_DEDUP === "0")
|
|
30
37
|
return "full";
|
|
@@ -32,7 +39,7 @@ export function injectionMode(sessionId, key, content) {
|
|
|
32
39
|
mkdirSync(dir, { recursive: true });
|
|
33
40
|
sweep(dir);
|
|
34
41
|
const file = join(dir, `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
|
|
35
|
-
const hash = createHash("sha256").update(
|
|
42
|
+
const hash = createHash("sha256").update(hashInput).digest("hex").slice(0, 16);
|
|
36
43
|
let map;
|
|
37
44
|
try {
|
|
38
45
|
const raw = JSON.parse(readFileSync(file, "utf8"));
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -1,10 +1,53 @@
|
|
|
1
1
|
export declare const HUNCH_DIR = ".hunch";
|
|
2
|
-
/** Canonicalize a free-form path/target to
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
/** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
|
|
3
|
+
* path with "/" — git emits it on all OSes — so any user- or agent-supplied
|
|
4
|
+
* target must be normalized before comparison, otherwise a Windows caller passing
|
|
5
|
+
* `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
|
|
6
|
+
* symbol names too: they contain no backslashes. This does NOT make a path
|
|
7
|
+
* repo-relative — an absolute path passes through with its separators flipped,
|
|
8
|
+
* unchanged otherwise; use `repoRelativeTarget` for that. */
|
|
7
9
|
export declare function toPosixTarget(target: string): string;
|
|
10
|
+
/** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
|
|
11
|
+
* to Write, or a glob whose concrete segments aren't literal): resolve the longest
|
|
12
|
+
* existing ancestor, then re-append the missing tail. Idempotent on an
|
|
13
|
+
* already-resolved path. */
|
|
14
|
+
export declare function realpathNorm(p: string): string;
|
|
15
|
+
/** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
|
|
16
|
+
* `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
|
|
17
|
+
* repo-relative, so a caller that hands over an absolute edit-payload path
|
|
18
|
+
* matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
|
|
19
|
+
* returned empty for an absolute target that plainly had a matching rule). A
|
|
20
|
+
* relative target, a glob, or an absolute path outside `root` passes through
|
|
21
|
+
* unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
|
|
22
|
+
*
|
|
23
|
+
* BOTH ends are realpath-normalized before the relative-path computation: on
|
|
24
|
+
* macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
|
|
25
|
+
* hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
|
|
26
|
+
* bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
|
|
27
|
+
* caller treats the file as outside the repo, silently dropping all context
|
|
28
|
+
* (dec_e0a36efbf5). */
|
|
29
|
+
export declare function repoRelativeTarget(target: string, root: string): string;
|
|
30
|
+
/** True when `target` (already repo-relative POSIX form) names a regular FILE
|
|
31
|
+
* that really exists inside `root`. The LAST-RESORT half of the "is this a real
|
|
32
|
+
* path" question: `isIndexedPath` answers it from graph data alone, but a real
|
|
33
|
+
* working-tree file with zero tree-sitter symbols and no covering component
|
|
34
|
+
* glob is invisible to it, so it fell through to the suffix tier and leaked an
|
|
35
|
+
* unrelated same-basename file's records (issue #334). Callers consult the
|
|
36
|
+
* index FIRST and only fall back here, so a deleted-but-still-indexed path and
|
|
37
|
+
* a time-travel (`asOf`) query keep answering exactly as before.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately narrow: an absolute path (or a Windows drive letter) and any
|
|
40
|
+
* target escaping `root` via ".." are rejected rather than resolved — the
|
|
41
|
+
* caller has already run `repoRelativeTarget`, so anything still absolute is
|
|
42
|
+
* outside the repo. A DIRECTORY is false: directory targets must keep flowing
|
|
43
|
+
* to `structure()`'s dir tier. Any fs error (missing, EACCES, ...) → false.
|
|
44
|
+
*
|
|
45
|
+
* Containment is checked twice: lexically, then again on the REALPATHS. `statSync`
|
|
46
|
+
* follows symlinks, so an in-repo `link -> /outside` would otherwise make
|
|
47
|
+
* `isRepoFile(root, "link/secret.ts")` true and turn this into a one-bit existence
|
|
48
|
+
* oracle for paths outside the repo. A symlinked file pointing at another file
|
|
49
|
+
* INSIDE the repo stays true. */
|
|
50
|
+
export declare function isRepoFile(root: string, target: string): boolean;
|
|
8
51
|
export interface HunchPaths {
|
|
9
52
|
/** Repo root (where .hunch/ lives). */
|
|
10
53
|
root: string;
|