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