@davesheffer/hunch 1.41.6 → 1.42.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 +1 -1
- package/dist/cli/index.js +402 -154
- package/dist/constitution/experiment.d.ts +3 -3
- package/dist/constitution/g3.d.ts +1 -1
- package/dist/core/footprint.d.ts +15 -0
- package/dist/core/footprint.js +167 -0
- package/dist/core/groundingLag.d.ts +15 -0
- package/dist/core/groundingLag.js +27 -0
- package/dist/core/hookText.d.ts +4 -0
- package/dist/core/hookText.js +8 -0
- package/dist/core/pipeline.d.ts +28 -0
- package/dist/core/pipeline.js +50 -0
- package/dist/core/shellwrites.d.ts +7 -0
- package/dist/core/shellwrites.js +131 -0
- package/dist/core/siblingfix.d.ts +124 -0
- package/dist/core/siblingfix.js +814 -0
- package/dist/core/taskReportHook.d.ts +1 -1
- package/dist/core/taskReportHook.js +19 -3
- package/dist/extractors/git.d.ts +31 -0
- package/dist/extractors/git.js +180 -1
- package/dist/extractors/nativeTreeSitter.d.ts +2 -1
- package/dist/extractors/nativeTreeSitter.js +128 -30
- package/dist/integrations/claudemd.d.ts +20 -2
- package/dist/integrations/claudemd.js +79 -53
- package/dist/integrations/providers.d.ts +9 -5
- package/dist/integrations/providers.js +34 -22
- package/dist/integrations/team.d.ts +24 -4
- package/dist/integrations/team.js +154 -16
- package/dist/integrations/worktree.d.ts +3 -2
- package/dist/integrations/worktree.js +7 -4
- package/dist/mcp/server.d.ts +489 -0
- package/dist/mcp/server.js +208 -62
- package/dist/mcp/taskReportTools.js +12 -9
- package/dist/mcp/toolset.d.ts +11 -1
- package/dist/mcp/toolset.js +28 -8
- package/dist/store/hunchStore.d.ts +25 -1
- package/dist/store/hunchStore.js +146 -21
- package/dist/store/jsonStore.js +25 -4
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -43,13 +43,13 @@ import { isParserLoadError, loadNativeTreeSitter } from "../extractors/nativeTre
|
|
|
43
43
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
44
44
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
45
45
|
import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
|
|
46
|
-
import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatus, mergeRangeChanges, commitsExist } from "../extractors/git.js";
|
|
46
|
+
import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatus, mergeRangeChanges, commitsExist } from "../extractors/git.js";
|
|
47
47
|
import { parseMemoryLog } from "../core/memorylog.js";
|
|
48
48
|
import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
|
|
49
49
|
import { orphanedCommitDecisions, planCommitRepair, repairDecisionCommit, pickRewrite, commitRepairReviewHash, mergeRewrites, firstFor, deadRewrites, resolvedRewriteIds, withoutDropped, addDropped, withheldForUnresolvableTo } from "../core/commitrepair.js";
|
|
50
50
|
import { readPendingRepairs, writePendingRepairs, readDroppedRepairs, writeDroppedRepairs, readActivePendingRepairs, withheldRewrites } from "../core/repairqueue.js";
|
|
51
51
|
import { planPolicyRepair, repairPolicySpec } from "../constitution/repairPolicies.js";
|
|
52
|
-
import { writeTeamConfig, ensureTeamOverlay, readTeamConfig, safeGitUrl, safeTeamRef, overlayMatchesTeamRemote, advertisedTeamRemoteContract, boundedTeamGitEnv, cloneValidatedTeamOverlay, explicitTeamRemoteContract, teamRemoteContract } from "../integrations/team.js";
|
|
52
|
+
import { writeTeamConfig, ensureTeamOverlay, readTeamConfig, isTeamStoreTrusted, teamWiringConsented, trustTeamStore, teamTrustFile, untrustedTeamStoreMessage, safeGitUrl, safeTeamRef, overlayMatchesTeamRemote, advertisedTeamRemoteContract, boundedTeamGitEnv, cloneValidatedTeamOverlay, explicitTeamRemoteContract, teamRemoteContract } from "../integrations/team.js";
|
|
53
53
|
import { runbookId, decisionId } from "../core/ids.js";
|
|
54
54
|
import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
|
|
55
55
|
import { extractInlineIntent } from "../extractors/comments.js";
|
|
@@ -62,6 +62,8 @@ import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
|
62
62
|
import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade } from "../integrations/gitignore.js";
|
|
63
63
|
import { writeCiWorkflow } from "../integrations/ciAction.js";
|
|
64
64
|
import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
|
|
65
|
+
import { HOOK_REMINDER } from "../core/hookText.js";
|
|
66
|
+
import { measureFootprint } from "../core/footprint.js";
|
|
65
67
|
import { classifyGroundingBlock, describeGroundingFreshness } from "../core/groundingLag.js";
|
|
66
68
|
import { mergeGroundingFile } from "../core/groundingMerge.js";
|
|
67
69
|
import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
|
|
@@ -98,7 +100,7 @@ import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, star
|
|
|
98
100
|
import { persistTaskRecord } from "../core/taskRecord.js";
|
|
99
101
|
import { recordHookObservation } from "../core/hookObservations.js";
|
|
100
102
|
import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
|
|
101
|
-
import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, unverifiedNag, } from "../core/pipeline.js";
|
|
103
|
+
import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, onLessonsDelivered, lessonReminder, unverifiedNag, } from "../core/pipeline.js";
|
|
102
104
|
import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
|
|
103
105
|
import { planAutoReview, planMutations } from "../core/autoreview.js";
|
|
104
106
|
import { loadGoldenSet, evaluateRetrieval, evaluateTraversalLift } from "../eval/harness.js";
|
|
@@ -116,6 +118,9 @@ import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../inte
|
|
|
116
118
|
import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline } from "../core/escalations.js";
|
|
117
119
|
import { premiseEscalations } from "../core/premises.js";
|
|
118
120
|
import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
|
|
121
|
+
import { pathMatchesGlob } from "../core/glob.js";
|
|
122
|
+
import { SIBLING_HEADING, functionBodyHash, siblingGrounding } from "../core/siblingfix.js";
|
|
123
|
+
import { refreshShellBaseline, shellWrittenFiles } from "../core/shellwrites.js";
|
|
119
124
|
import { compareCandidates } from "../core/compare.js";
|
|
120
125
|
import { compareCodeUnits } from "../core/canonicalOrder.js";
|
|
121
126
|
import { MAX_LANDSCAPE_REFRESH_REVISIONS, planLandscapeAdoption, } from "../core/landscapeAdoption.js";
|
|
@@ -246,7 +251,8 @@ function openTeamStore(root, opts = {}) {
|
|
|
246
251
|
const explicitOverlay = !!process.env.HUNCH_PRIVATE_DIR?.trim();
|
|
247
252
|
const teamFile = join(hunchPaths(root).hunch, "team.json");
|
|
248
253
|
const teamAdvertised = !explicitOverlay && existsSync(teamFile);
|
|
249
|
-
|
|
254
|
+
const advertisedTeam = teamAdvertised ? readTeamConfig(root) : null;
|
|
255
|
+
if (teamAdvertised && !advertisedTeam) {
|
|
250
256
|
throw new Error(".hunch/team.json is invalid or unsafe; refusing to fall back to public memory");
|
|
251
257
|
}
|
|
252
258
|
const teamWired = ensureTeamOverlay(root);
|
|
@@ -255,12 +261,16 @@ function openTeamStore(root, opts = {}) {
|
|
|
255
261
|
const overlayWarning = store.overlayResolutionWarning(explicitOverlay && existsSync(teamFile));
|
|
256
262
|
if (overlayWarning)
|
|
257
263
|
console.error(`[hunch] ⚠ ${overlayWarning}`);
|
|
258
|
-
if (
|
|
264
|
+
if (advertisedTeam && (store.mode !== "shared"
|
|
259
265
|
|| !store.privateDir
|
|
260
266
|
|| !existsSync(store.privateDir)
|
|
267
|
+
|| !teamWiringConsented(root, advertisedTeam, store.privateDir)
|
|
261
268
|
|| !overlayMatchesTeamRemote(root, dirname(store.privateDir)))) {
|
|
269
|
+
const consented = !!store.privateDir && teamWiringConsented(root, advertisedTeam, store.privateDir);
|
|
262
270
|
store.close();
|
|
263
271
|
openStore = null;
|
|
272
|
+
if (!consented && !isTeamStoreTrusted(root, advertisedTeam))
|
|
273
|
+
throw new Error(untrustedTeamStoreMessage(advertisedTeam));
|
|
264
274
|
throw new Error("the advertised team memory store is unavailable or tracks a different remote; refusing to read or write another graph");
|
|
265
275
|
}
|
|
266
276
|
// Short-lived CLI processes need the same live edge as the long-lived MCP
|
|
@@ -487,7 +497,7 @@ program
|
|
|
487
497
|
// Worktree-seamless: register any configured overlay at the git common dir so EVERY
|
|
488
498
|
// worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
|
|
489
499
|
// and note when we're initializing inside a linked worktree (memory is shared, not separate).
|
|
490
|
-
if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private")) {
|
|
500
|
+
if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private", true)) {
|
|
491
501
|
console.log(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
|
|
492
502
|
}
|
|
493
503
|
for (const line of sharedHooksNote(root, installs))
|
|
@@ -1014,15 +1024,18 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
|
|
|
1014
1024
|
const localFile = join(hunchPaths(root).hunch, "local.json");
|
|
1015
1025
|
const teamFile = join(hunchPaths(root).hunch, "team.json");
|
|
1016
1026
|
const codeGitignore = join(root, ".gitignore");
|
|
1017
|
-
const commonDir =
|
|
1027
|
+
const commonDir = checkoutCommonDir(root) || separateGitDirCandidate(root)?.gitDir || "";
|
|
1018
1028
|
const sharedPointer = commonDir ? join(commonDir, "hunch", "local.json") : "";
|
|
1029
|
+
// Registering the shared pointer can also claim a separate git dir (claimSeparateGitDir),
|
|
1030
|
+
// writing its checkout back-link beside the pointer; a failed setup must not leave it.
|
|
1031
|
+
const backLink = commonDir ? separateGitDirLink(commonDir) : "";
|
|
1019
1032
|
const configuredHooks = includeHook ? hooksDir(root) : "";
|
|
1020
1033
|
const hookDir = configuredHooks ? (isAbsolute(configuredHooks) ? configuredHooks : join(root, configuredHooks)) : "";
|
|
1021
1034
|
// Setup installs post-commit AND post-merge together, so both must be in the
|
|
1022
1035
|
// ledger — restoring one while leaving the other pointing at a just-deleted
|
|
1023
1036
|
// overlay is not a rollback.
|
|
1024
1037
|
const hookFiles = hookDir ? [join(hookDir, "post-commit"), join(hookDir, "post-merge")] : [];
|
|
1025
|
-
const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer] : []), ...hookFiles];
|
|
1038
|
+
const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer, backLink] : []), ...hookFiles];
|
|
1026
1039
|
const snapshots = new Map(paths.map((path) => [path, setupPathSnapshot(path)]));
|
|
1027
1040
|
const parentExisted = new Map([
|
|
1028
1041
|
[dirname(localFile), existsSync(dirname(localFile))],
|
|
@@ -1041,7 +1054,7 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
|
|
|
1041
1054
|
markLocalWrite: () => mark(localFile),
|
|
1042
1055
|
markGitignoreWrite: () => mark(codeGitignore),
|
|
1043
1056
|
markTeamWrite: () => mark(teamFile),
|
|
1044
|
-
markSharedPointerWrite: () => mark(sharedPointer),
|
|
1057
|
+
markSharedPointerWrite: () => { mark(backLink); mark(sharedPointer); },
|
|
1045
1058
|
markHookWrite: () => hookFiles.forEach(mark),
|
|
1046
1059
|
// Migration is a one-way ownership handoff. Once public records have been
|
|
1047
1060
|
// durably copied into this clone, a later setup failure may restore routing
|
|
@@ -1070,6 +1083,74 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
|
|
|
1070
1083
|
},
|
|
1071
1084
|
};
|
|
1072
1085
|
}
|
|
1086
|
+
/** `hunch shared --trust`: the one explicit step that lets this machine auto-wire
|
|
1087
|
+
* the store a committed team.json advertises. Shows the exact URL being trusted. */
|
|
1088
|
+
function trustAdvertisedTeamStore(root, dir, opts) {
|
|
1089
|
+
if (dir || opts.repo || opts.sync || opts.migrate) {
|
|
1090
|
+
return fail("--trust takes no directory and cannot be combined with --repo, --sync, or --migrate");
|
|
1091
|
+
}
|
|
1092
|
+
if (process.env.HUNCH_PRIVATE_DIR?.trim()) {
|
|
1093
|
+
return fail("HUNCH_PRIVATE_DIR is set, so .hunch/team.json is bypassed; unset it before trusting the team store");
|
|
1094
|
+
}
|
|
1095
|
+
const team = readTeamConfig(root);
|
|
1096
|
+
if (!team) {
|
|
1097
|
+
return fail(existsSync(join(hunchPaths(root).hunch, "team.json"))
|
|
1098
|
+
? ".hunch/team.json is invalid or unsafe; refusing to trust it"
|
|
1099
|
+
: "no .hunch/team.json in this repository; nothing to trust");
|
|
1100
|
+
}
|
|
1101
|
+
// Consent persists only after a successful connect: openTeamStore reads the trust
|
|
1102
|
+
// entry, so it is written first and restored to the exact prior state on failure.
|
|
1103
|
+
// The connect can also write routing BEFORE it fails (this checkout's pointer, the
|
|
1104
|
+
// registered common-dir pointer, a separate git dir's back-link), and a registered
|
|
1105
|
+
// pointer is itself consent, so those are snapshotted and restored too — otherwise
|
|
1106
|
+
// "trust was NOT recorded" would be false in substance.
|
|
1107
|
+
const priorTrust = isTeamStoreTrusted(root, team);
|
|
1108
|
+
const commonDir = checkoutCommonDir(root) || separateGitDirCandidate(root)?.gitDir || "";
|
|
1109
|
+
const routing = [
|
|
1110
|
+
join(hunchPaths(root).hunch, "local.json"),
|
|
1111
|
+
...(commonDir ? [join(commonDir, "hunch", "local.json"), separateGitDirLink(commonDir)] : []),
|
|
1112
|
+
];
|
|
1113
|
+
const routingBefore = routing.map((path) => [path, setupPathSnapshot(path)]);
|
|
1114
|
+
const undoTrust = trustTeamStore(root, team);
|
|
1115
|
+
let opened;
|
|
1116
|
+
try {
|
|
1117
|
+
opened = openTeamStore(root);
|
|
1118
|
+
}
|
|
1119
|
+
catch (error) {
|
|
1120
|
+
let reverted = true;
|
|
1121
|
+
for (const [path, before] of routingBefore) {
|
|
1122
|
+
try {
|
|
1123
|
+
restoreSetupPath(path, before);
|
|
1124
|
+
}
|
|
1125
|
+
catch {
|
|
1126
|
+
reverted = false;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
try {
|
|
1130
|
+
undoTrust();
|
|
1131
|
+
}
|
|
1132
|
+
catch {
|
|
1133
|
+
reverted = false;
|
|
1134
|
+
}
|
|
1135
|
+
// The undo only removes THIS invocation's own entry: a trust entry this checkout
|
|
1136
|
+
// already had stays, and a concurrent process may have recorded (and kept) its own.
|
|
1137
|
+
const stillConsented = reverted && isTeamStoreTrusted(root, team);
|
|
1138
|
+
return fail(`could not connect to the team store: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
1139
|
+
(stillConsented
|
|
1140
|
+
? priorTrust
|
|
1141
|
+
? ` · this checkout's earlier trust entry is kept (${teamTrustFile()})\n`
|
|
1142
|
+
: ` · trust for this checkout was recorded by another process meanwhile (${teamTrustFile()})\n`
|
|
1143
|
+
: reverted
|
|
1144
|
+
? ` · trust was NOT recorded for this checkout (${teamTrustFile()} and its store pointers are unchanged)\n`
|
|
1145
|
+
: ` · trust could not be fully rolled back: remove this checkout's entry from ${teamTrustFile()} and its .hunch/local.json store pointer\n`) +
|
|
1146
|
+
" · retry: `hunch shared --trust` once the store is reachable");
|
|
1147
|
+
}
|
|
1148
|
+
console.log(`✓ trusted the team memory store on this machine → ${team.shared_repo}`);
|
|
1149
|
+
const { store, teamWired } = opened;
|
|
1150
|
+
console.log(teamWired
|
|
1151
|
+
? ` ✓ connected to the team's shared memory store → ${teamWired}`
|
|
1152
|
+
: ` · already connected → ${store.privateDir}`);
|
|
1153
|
+
}
|
|
1073
1154
|
function configureOverlay(dir, opts, mode) {
|
|
1074
1155
|
let freshSetup = null;
|
|
1075
1156
|
let setupComplete = false;
|
|
@@ -1077,6 +1158,8 @@ function configureOverlay(dir, opts, mode) {
|
|
|
1077
1158
|
const root = findRoot();
|
|
1078
1159
|
const paths = hunchPaths(root);
|
|
1079
1160
|
const commandName = mode === "private" ? "private" : "shared";
|
|
1161
|
+
if (opts.trust)
|
|
1162
|
+
return trustAdvertisedTeamStore(root, dir, opts);
|
|
1080
1163
|
// A repository URL reaches Git before the overlay is trusted in BOTH modes.
|
|
1081
1164
|
// Keep private split stores private by omitting team.json, not by weakening the
|
|
1082
1165
|
// clone transport gate: credentials stay in normal Git helpers and every setup
|
|
@@ -1261,6 +1344,10 @@ function configureOverlay(dir, opts, mode) {
|
|
|
1261
1344
|
return fail("could not select one canonical branch for the shared memory repository");
|
|
1262
1345
|
freshSetup?.markTeamWrite();
|
|
1263
1346
|
writeTeamConfig(root, { shared_repo: opts.repo, shared_ref: sharedRef });
|
|
1347
|
+
// The author typed this URL, which is the consent teammates give with --trust.
|
|
1348
|
+
const published = readTeamConfig(root);
|
|
1349
|
+
if (published)
|
|
1350
|
+
trustTeamStore(root, published);
|
|
1264
1351
|
// Bind the graph epoch immediately, in clone-local Git metadata. Waiting
|
|
1265
1352
|
// until the next command would let a coherent team.json+origin repoint
|
|
1266
1353
|
// relabel this clone after setup but before its first normal open.
|
|
@@ -1276,9 +1363,12 @@ function configureOverlay(dir, opts, mode) {
|
|
|
1276
1363
|
// only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
|
|
1277
1364
|
let worktreeNote = "";
|
|
1278
1365
|
freshSetup?.markSharedPointerWrite();
|
|
1279
|
-
if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit, mode)) {
|
|
1366
|
+
if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit, mode, true)) {
|
|
1280
1367
|
worktreeNote = " ✓ registered in the git common dir — shared by every worktree of this repo, on any branch\n";
|
|
1281
1368
|
}
|
|
1369
|
+
else {
|
|
1370
|
+
worktreeNote = " ⚠ could not register the overlay in this checkout's git common dir, so Hunch will not use .hunch/local.json here; set HUNCH_PRIVATE_DIR to this overlay instead\n";
|
|
1371
|
+
}
|
|
1282
1372
|
// 4) route post-commit synthesis to the overlay (local hook, never committed)
|
|
1283
1373
|
let hookNote = "";
|
|
1284
1374
|
if (opts.hook && isGitRepo(root)) {
|
|
@@ -1393,6 +1483,7 @@ program
|
|
|
1393
1483
|
.option("--no-auto-commit", "DON'T auto commit+push the overlay after each capture (default: ON — fully automated two-way sync)")
|
|
1394
1484
|
.option("--sync", "flush the configured overlay store now (git add+commit+push)")
|
|
1395
1485
|
.option("--migrate", "ONE-TIME: move this repo's EXISTING public .hunch memory into the shared overlay, then make the public repo code-only")
|
|
1486
|
+
.option("--trust", "trust the shared store advertised in the committed .hunch/team.json on this machine, then connect to it")
|
|
1396
1487
|
.action((dir, opts) => configureOverlay(dir, opts, "shared"));
|
|
1397
1488
|
// ---- worktree (one-command worktree wired into Hunch) ----------------------
|
|
1398
1489
|
program
|
|
@@ -1427,7 +1518,7 @@ program
|
|
|
1427
1518
|
if (!opts.share) {
|
|
1428
1519
|
shareNote = ` · --no-share — the worktree will NOT see private memory`;
|
|
1429
1520
|
}
|
|
1430
|
-
else if (overlay && ensureSharedOverlayPointer(root, overlay, autoCommit, overlayMode)) {
|
|
1521
|
+
else if (overlay && ensureSharedOverlayPointer(root, overlay, autoCommit, overlayMode, true)) {
|
|
1431
1522
|
shareNote = ` ✓ memory shared via the git common dir — this worktree sees the same decisions / bugs / constraints`;
|
|
1432
1523
|
}
|
|
1433
1524
|
else if (overlay) {
|
|
@@ -4452,6 +4543,7 @@ program
|
|
|
4452
4543
|
.option("--profile <profile>", "delivery role: builder, reviewer, or architect", "builder")
|
|
4453
4544
|
.option("--as-of <ref>", "time-travel: assemble the slice as it stood at a commit/tag/branch")
|
|
4454
4545
|
.option("--task <id>", "retain the exact context delivery for this task's contribution report")
|
|
4546
|
+
.option("--include <kinds>", "opt-in extras (comma list): recent-tasks,project-dna — omitted by default to keep the brief small")
|
|
4455
4547
|
.action(async (target, opts) => {
|
|
4456
4548
|
if (!DELIVERY_PROFILES.includes(opts.profile)) {
|
|
4457
4549
|
return fail(`--profile must be one of: ${DELIVERY_PROFILES.join(", ")}`);
|
|
@@ -4477,6 +4569,27 @@ program
|
|
|
4477
4569
|
// receipts matching the target — the same slice and render as hunch_context.
|
|
4478
4570
|
const slice = asOf ? null : store.stateSlice(target);
|
|
4479
4571
|
const stateGrounding = slice ? stateSupplements(slice, target) : [];
|
|
4572
|
+
// Both extras below cost brief tokens (and, for DNA/recent-tasks selection, extra
|
|
4573
|
+
// latency) — opt-in only via --include, omitted from the default brief.
|
|
4574
|
+
// Accept the MCP spelling (recent_tasks) too, so a name copied from the tool schema works.
|
|
4575
|
+
const include = opts.include ? opts.include.split(",").map((s) => s.trim().replace(/_/g, "-")).filter(Boolean) : [];
|
|
4576
|
+
const unknownInclude = include.filter((k) => k !== "recent-tasks" && k !== "project-dna");
|
|
4577
|
+
if (unknownInclude.length)
|
|
4578
|
+
return fail(`--include accepts recent-tasks, project-dna (got: ${unknownInclude.join(", ")})`);
|
|
4579
|
+
const recentTasks = (asOf || !include.includes("recent-tasks"))
|
|
4580
|
+
? []
|
|
4581
|
+
: taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, opts.task ?? null, target)), target);
|
|
4582
|
+
let dnaSupplement = null;
|
|
4583
|
+
if (!asOf && include.includes("project-dna")) {
|
|
4584
|
+
try {
|
|
4585
|
+
dnaSupplement = projectDnaDeliverySupplement(discoverProjectDna(root, "HEAD"));
|
|
4586
|
+
}
|
|
4587
|
+
catch {
|
|
4588
|
+
// Same graceful behavior as hunch_context: a missing/unreadable Git checkout
|
|
4589
|
+
// must not break the CLI brief; the dedicated `hunch project-dna` command
|
|
4590
|
+
// reports the exact derivation error when a caller needs diagnostics.
|
|
4591
|
+
}
|
|
4592
|
+
}
|
|
4480
4593
|
if (empty && !asOf && opts.task) {
|
|
4481
4594
|
const resolved = store.rankedSearch(target, 8).map(hit => ({ hit, record: store.resolve(hit.ref)?.record }));
|
|
4482
4595
|
ctx = { ...ctx,
|
|
@@ -4512,7 +4625,7 @@ program
|
|
|
4512
4625
|
decisionCorpus: store.recs("decisions"),
|
|
4513
4626
|
historical: !!asOf,
|
|
4514
4627
|
profile: opts.profile,
|
|
4515
|
-
supplements: [...
|
|
4628
|
+
supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding, ...recentTasks],
|
|
4516
4629
|
});
|
|
4517
4630
|
process.stdout.write(envelope.text);
|
|
4518
4631
|
if (opts.task) {
|
|
@@ -4856,10 +4969,39 @@ program
|
|
|
4856
4969
|
// Verification pipeline (delivery enforced, not hoped for — see core/pipeline.ts).
|
|
4857
4970
|
// PostToolUse records facts; Stop gates on them. Both are pipeline-only events,
|
|
4858
4971
|
// handled before the grounding dispatch below.
|
|
4859
|
-
|
|
4972
|
+
const postTool = evt.hook_event_name === "PostToolUse" || evt.hook_event_name === "PostToolUseFailure" ? evt.hook_event_name : null;
|
|
4973
|
+
// A shell command can edit files without the edit tools ever firing: ground
|
|
4974
|
+
// what it wrote now. Every other tool call just moves the baseline.
|
|
4975
|
+
let shellGround = "";
|
|
4976
|
+
if (postTool) {
|
|
4977
|
+
// Fail-open on its own: a grounding error must not cost the pipeline
|
|
4978
|
+
// bookkeeping below (a missed check would wrongly hold the Stop gate).
|
|
4979
|
+
try {
|
|
4980
|
+
if (evt.tool_name === "Bash" || evt.tool_name === "PowerShell") {
|
|
4981
|
+
const written = shellWrittenFiles(root, evt.session_id, evt.agent_id);
|
|
4982
|
+
if (written.length) {
|
|
4983
|
+
const opened = openTeamStore(root, { requireFreshTeamMemory: firmness === "strict" });
|
|
4984
|
+
store = opened.store;
|
|
4985
|
+
// Same rule as the pre-edit path: strict never grounds from stale team rules.
|
|
4986
|
+
const stale = firmness === "strict" && opened.teamPullStatus
|
|
4987
|
+
&& opened.teamPullStatus !== "updated" && opened.teamPullStatus !== "current";
|
|
4988
|
+
if (!stale)
|
|
4989
|
+
shellGround = shellWriteGrounding(root, store, provider, evt, written);
|
|
4990
|
+
}
|
|
4991
|
+
}
|
|
4992
|
+
else {
|
|
4993
|
+
refreshShellBaseline(root, evt.session_id, evt.agent_id);
|
|
4994
|
+
}
|
|
4995
|
+
}
|
|
4996
|
+
catch {
|
|
4997
|
+
shellGround = "";
|
|
4998
|
+
}
|
|
4999
|
+
}
|
|
5000
|
+
if (postTool && evt.session_id && pipelineEnabled()) {
|
|
4860
5001
|
let st = loadPipelineState(evt.session_id);
|
|
4861
5002
|
const before = st;
|
|
4862
5003
|
let activity = null;
|
|
5004
|
+
let lessonNote = "";
|
|
4863
5005
|
if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
|
|
4864
5006
|
// A Codex apply_patch touches every file it lists (and each Move-to
|
|
4865
5007
|
// destination); the Stop gate must see all of them, not only the first.
|
|
@@ -4876,6 +5018,13 @@ program
|
|
|
4876
5018
|
const command = String(evt.tool_input?.command ?? "");
|
|
4877
5019
|
st = onCommand(st, command, evt.tool_outcome);
|
|
4878
5020
|
activity = { kind: "command", command };
|
|
5021
|
+
// A command that both wrote the file and ran a check just received the
|
|
5022
|
+
// lesson itself; the follow-up waits for the next check.
|
|
5023
|
+
if (!shellGround) {
|
|
5024
|
+
const followUp = lessonReminder(st, command, (l) => functionBodyHash(root, l.file, l.symbol));
|
|
5025
|
+
st = followUp.state;
|
|
5026
|
+
lessonNote = followUp.reminder;
|
|
5027
|
+
}
|
|
4879
5028
|
}
|
|
4880
5029
|
else if (evt.tool_name === "Skill") {
|
|
4881
5030
|
st = onSkill(st, String(evt.tool_input?.skill ?? ""));
|
|
@@ -4885,11 +5034,19 @@ program
|
|
|
4885
5034
|
const checkpoint = proofCheckpoint(before, st, activity);
|
|
4886
5035
|
st = checkpoint.state;
|
|
4887
5036
|
savePipelineState(evt.session_id, st);
|
|
4888
|
-
|
|
4889
|
-
|
|
5037
|
+
const post = [shellGround, lessonNote, checkpoint.reminder].filter(Boolean).join("\n\n");
|
|
5038
|
+
if (post)
|
|
5039
|
+
emitContext(provider, postTool, post);
|
|
4890
5040
|
return;
|
|
4891
5041
|
}
|
|
4892
5042
|
savePipelineState(evt.session_id, st);
|
|
5043
|
+
if (shellGround)
|
|
5044
|
+
emitContext(provider, postTool, shellGround);
|
|
5045
|
+
return;
|
|
5046
|
+
}
|
|
5047
|
+
if (postTool) {
|
|
5048
|
+
if (shellGround)
|
|
5049
|
+
emitContext(provider, postTool, shellGround);
|
|
4893
5050
|
return;
|
|
4894
5051
|
}
|
|
4895
5052
|
if (evt.hook_event_name === "Stop") {
|
|
@@ -4922,22 +5079,29 @@ program
|
|
|
4922
5079
|
// When the prompt reads like a correction ("no / that's wrong / never X"),
|
|
4923
5080
|
// nudge the agent to PERSIST it as an enforced constraint (Never Twice) —
|
|
4924
5081
|
// not just obey it this once and forget it next session.
|
|
5082
|
+
// Shell writes are measured from the start of each prompt.
|
|
5083
|
+
refreshShellBaseline(root, evt.session_id);
|
|
4925
5084
|
const isCorrection = looksLikeCorrection(evt.prompt);
|
|
4926
|
-
|
|
4927
|
-
//
|
|
4928
|
-
//
|
|
4929
|
-
//
|
|
4930
|
-
//
|
|
4931
|
-
//
|
|
4932
|
-
//
|
|
4933
|
-
|
|
5085
|
+
// Once per session is enough for the bare availability reminder — repeating it
|
|
5086
|
+
// every prompt burns context for zero information (dec_244397d920). It is deduped
|
|
5087
|
+
// on its OWN key: hashed together with the per-prompt task report (a new ID each
|
|
5088
|
+
// prompt) it was re-sent on every prompt. A correction is never deduped: two
|
|
5089
|
+
// back-to-back corrections are byte-identical, and the second (usually the
|
|
5090
|
+
// escalating one) must still become an enforced rule. Neither is the unverified
|
|
5091
|
+
// nag below, the one nag that must repeat.
|
|
5092
|
+
const parts = [];
|
|
5093
|
+
// Always stamp the key, so a correction that delivers the reminder counts as
|
|
5094
|
+
// this session's one delivery.
|
|
5095
|
+
const reminderDue = injectionMode(evt.session_id, "prompt-reminder", HOOK_REMINDER) === "full";
|
|
5096
|
+
if (isCorrection)
|
|
5097
|
+
parts.push(`${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}`);
|
|
5098
|
+
else if (reminderDue)
|
|
5099
|
+
parts.push(HOOK_REMINDER);
|
|
4934
5100
|
// Reporting failure must not suppress the existing correction/policy reminder.
|
|
4935
5101
|
try {
|
|
4936
5102
|
const report = startHookReport(root, provider, evt);
|
|
4937
|
-
if (report)
|
|
4938
|
-
|
|
4939
|
-
mustDeliver = true;
|
|
4940
|
-
}
|
|
5103
|
+
if (report)
|
|
5104
|
+
parts.push(report);
|
|
4941
5105
|
}
|
|
4942
5106
|
catch { /* passive reporting remains fail-open */ }
|
|
4943
5107
|
// A task an earlier prompt of this session left open (interrupted before
|
|
@@ -4957,16 +5121,12 @@ program
|
|
|
4957
5121
|
const st = onPrompt(loadPipelineState(evt.session_id));
|
|
4958
5122
|
savePipelineState(evt.session_id, st);
|
|
4959
5123
|
if (!st.verifyAfterEdit || st.obligations.some((item) => item.status !== "satisfied")) {
|
|
4960
|
-
|
|
4961
|
-
mustDeliver = true;
|
|
5124
|
+
parts.push(unverifiedNag(st));
|
|
4962
5125
|
}
|
|
4963
5126
|
}
|
|
4964
|
-
|
|
4965
|
-
// every prompt burns context for zero information (dec_244397d920). Only that
|
|
4966
|
-
// ambient case is deduped.
|
|
4967
|
-
if (!mustDeliver && injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
|
|
5127
|
+
if (!parts.length)
|
|
4968
5128
|
return;
|
|
4969
|
-
emitContext(provider, "UserPromptSubmit",
|
|
5129
|
+
emitContext(provider, "UserPromptSubmit", parts.join("\n\n"));
|
|
4970
5130
|
return;
|
|
4971
5131
|
}
|
|
4972
5132
|
if (evt.hook_event_name === "PreCompact") {
|
|
@@ -4991,6 +5151,12 @@ program
|
|
|
4991
5151
|
// would cross worktrees; stay silent instead of guessing which side is right.
|
|
4992
5152
|
if ((provider === "claude" || provider === "codex") && evt.cwd !== undefined && !routedCwd)
|
|
4993
5153
|
return;
|
|
5154
|
+
// The prompt's baseline is the session's, not this agent's: without its
|
|
5155
|
+
// own, a first tool call that is a shell write would go ungrounded.
|
|
5156
|
+
// Without an agent id the key would be the SESSION's baseline, and
|
|
5157
|
+
// refreshing it here could swallow a parent write not yet grounded.
|
|
5158
|
+
if (evt.agent_id)
|
|
5159
|
+
refreshShellBaseline(root, evt.session_id, evt.agent_id);
|
|
4994
5160
|
const s = new HunchStore(paths);
|
|
4995
5161
|
try {
|
|
4996
5162
|
const clip1 = (text, max) => {
|
|
@@ -5081,10 +5247,11 @@ program
|
|
|
5081
5247
|
return;
|
|
5082
5248
|
}
|
|
5083
5249
|
if (evt.hook_event_name === "SessionStart") {
|
|
5084
|
-
// A compact-resume
|
|
5085
|
-
//
|
|
5086
|
-
// the session gets delta one-liners
|
|
5087
|
-
|
|
5250
|
+
// A compact-resume (or a cleared context that keeps its session id) means
|
|
5251
|
+
// everything injected so far is gone — the dedup map must forget it
|
|
5252
|
+
// delivered anything, or the rest of the session gets delta one-liners
|
|
5253
|
+
// against grounding and task rules the agent no longer has.
|
|
5254
|
+
if (evt.source === "compact" || evt.source === "clear")
|
|
5088
5255
|
resetSessionInjections(evt.session_id);
|
|
5089
5256
|
// Orientation at the moment it matters: what just happened + what's next,
|
|
5090
5257
|
// straight from the graph — the agent sits down already knowing where it
|
|
@@ -5250,115 +5417,10 @@ program
|
|
|
5250
5417
|
}
|
|
5251
5418
|
}
|
|
5252
5419
|
// advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
// graph outranks the prose being edited, and a stale pin is called out inline.
|
|
5256
|
-
let docGround = "";
|
|
5257
|
-
if (/\.(md|mdx)$/i.test(target)) {
|
|
5258
|
-
try {
|
|
5259
|
-
docGround = renderDocGrounding(parseDocAnchors(readFileSync(abs, "utf8")), store.recs("decisions"));
|
|
5260
|
-
}
|
|
5261
|
-
catch { /* unreadable / not yet created — no doc grounding */ }
|
|
5262
|
-
}
|
|
5263
|
-
const ctx = store.assembleContext(target);
|
|
5264
|
-
// Regression Guard (edit-time grounding): what an in-force decision retired
|
|
5265
|
-
// from this file. No diff exists yet, so this is context — "don't re-add X" —
|
|
5266
|
-
// not a block; the commit-time `hunch check` does the actual gating.
|
|
5267
|
-
const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
|
|
5268
|
-
const recentTasks = taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, hookReportTaskId(root, provider, evt), target, { excludeTargetDeliveries: true })), target);
|
|
5269
|
-
const hasContent = ctx.constraints.length ||
|
|
5270
|
-
ctx.decisions.length ||
|
|
5271
|
-
ctx.bugs.length ||
|
|
5272
|
-
ctx.blast_radius.length ||
|
|
5273
|
-
ctx.findings.length ||
|
|
5274
|
-
ctx.landscape?.resources.length ||
|
|
5275
|
-
ctx.landscape?.relationships.length ||
|
|
5276
|
-
retired.length ||
|
|
5277
|
-
recentTasks.length ||
|
|
5278
|
-
docGround;
|
|
5279
|
-
if (!hasContent)
|
|
5420
|
+
const grounded = fileGrounding(root, store, provider, evt, target, abs);
|
|
5421
|
+
if (!grounded)
|
|
5280
5422
|
return; // no noise on files Hunch hasn't learned yet
|
|
5281
|
-
|
|
5282
|
-
...(retired.length ? [{
|
|
5283
|
-
id: "retired-code",
|
|
5284
|
-
kind: "retired-code",
|
|
5285
|
-
priority: 200,
|
|
5286
|
-
text: `⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ")}.`,
|
|
5287
|
-
}] : []),
|
|
5288
|
-
...(docGround ? [{ id: "doc-grounding", kind: "doc-grounding", priority: 100, text: docGround }] : []),
|
|
5289
|
-
...recentTasks,
|
|
5290
|
-
];
|
|
5291
|
-
const envelope = buildDeliveryEnvelope(ctx, {
|
|
5292
|
-
profile: "builder",
|
|
5293
|
-
root,
|
|
5294
|
-
symbols: store.recs("symbols"),
|
|
5295
|
-
components: store.recs("components"),
|
|
5296
|
-
decisionCorpus: store.recs("decisions"),
|
|
5297
|
-
supplements,
|
|
5298
|
-
});
|
|
5299
|
-
const text = envelope.text.trim();
|
|
5300
|
-
// Identical grounding already shown this session → one-line delta instead of
|
|
5301
|
-
// the full 10-16KB block. Any record change re-sends the full text; the
|
|
5302
|
-
// strict-gate deny path above never routes through this (dec_244397d920).
|
|
5303
|
-
// Delivery receipts (dec_925f4bcaad): the ledger of what actually reached
|
|
5304
|
-
// an agent. A full injection is a serve; a delta one-liner attests the
|
|
5305
|
-
// earlier serve is still standing. Never throws, never blocks.
|
|
5306
|
-
const receipts = (event) => recordServed(root, [
|
|
5307
|
-
...envelope.delivered.map((item) => ({
|
|
5308
|
-
event,
|
|
5309
|
-
kind: item.kind,
|
|
5310
|
-
record_id: item.record_id,
|
|
5311
|
-
target,
|
|
5312
|
-
session_id: evt.session_id,
|
|
5313
|
-
rank: item.rank,
|
|
5314
|
-
delivery_reason: item.delivery_reason,
|
|
5315
|
-
provenance_status: item.provenance_status,
|
|
5316
|
-
token_cost: item.token_cost,
|
|
5317
|
-
delivery_profile: envelope.profile,
|
|
5318
|
-
ranking_policy: envelope.ranking_policy,
|
|
5319
|
-
})),
|
|
5320
|
-
// Delivered task lines are receipts too: they feed access-based recency.
|
|
5321
|
-
...envelope.supplements.filter((s) => s.kind === "recent-task" && s.delivered).map((s) => ({
|
|
5322
|
-
event, kind: "tasks", record_id: s.id, target, session_id: evt.session_id,
|
|
5323
|
-
rank: s.rank, delivery_reason: "supplemental", token_cost: s.token_cost,
|
|
5324
|
-
delivery_profile: envelope.profile, ranking_policy: envelope.ranking_policy,
|
|
5325
|
-
})),
|
|
5326
|
-
]);
|
|
5327
|
-
const reportTaskId = hookReportTaskId(root, provider, evt);
|
|
5328
|
-
// A new authoritative prompt gets its own full delivery. An earlier
|
|
5329
|
-
// prompt's session-level delta cannot establish this task's receipt.
|
|
5330
|
-
// A subagent reports to the prompt's task but starts with FRESH context:
|
|
5331
|
-
// it never saw that grounding, so its dedup is scoped by its own agent
|
|
5332
|
-
// identity (hashed — the raw agent_id is never retained in the key).
|
|
5333
|
-
const agentKey = evt.agent_id ? `:${reportHash(evt.agent_id).slice(7, 19)}` : "";
|
|
5334
|
-
// Dedup on the envelope's stable IDENTITY projection, never on the
|
|
5335
|
-
// rendered block: serving the full text writes delivery receipts, and the
|
|
5336
|
-
// next call's task ranking reads them back and moves the wording ("today"
|
|
5337
|
-
// → "delivered today"), so hashing the presentation made this grounding
|
|
5338
|
-
// self-invalidating and re-sent the full block for unchanged records.
|
|
5339
|
-
if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}${agentKey}`, text, deliveryDedupeInput(envelope, supplements)) === "delta") {
|
|
5340
|
-
receipts("refreshed");
|
|
5341
|
-
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).`);
|
|
5342
|
-
return;
|
|
5343
|
-
}
|
|
5344
|
-
receipts("served");
|
|
5345
|
-
let reportNotice = "";
|
|
5346
|
-
let recalled = null;
|
|
5347
|
-
if (reportTaskId) {
|
|
5348
|
-
try {
|
|
5349
|
-
const snapshots = snapshotDeliveredRecords(store, envelope);
|
|
5350
|
-
// The first time a lesson reaches this prompt's task, tell the USER in one
|
|
5351
|
-
// line (systemMessage); repeats of the same revision stay silent.
|
|
5352
|
-
recalled = reportPresentationEnabled(root) ? renderRecalledLine(unseenLessons(root, reportTaskId, snapshots)) : null;
|
|
5353
|
-
const occurrence = recordTaskDelivery(root, reportTaskId, envelope, snapshots, undefined, target);
|
|
5354
|
-
reportNotice = `\n\nHunch task ${reportTaskId} · delivery ${occurrence}. Inspect exact application references with hunch_report(task_id).`;
|
|
5355
|
-
}
|
|
5356
|
-
catch {
|
|
5357
|
-
reportNotice = "\n\nTask report observation unavailable; this delivery's task contribution remains unverified.";
|
|
5358
|
-
recalled = null;
|
|
5359
|
-
}
|
|
5360
|
-
}
|
|
5361
|
-
emitContext(provider, "PreToolUse", text + reportNotice, recalled ?? undefined);
|
|
5423
|
+
emitContext(provider, "PreToolUse", grounded.text, grounded.mode === "full" ? grounded.recalled ?? undefined : undefined);
|
|
5362
5424
|
}
|
|
5363
5425
|
catch (e) {
|
|
5364
5426
|
// Never block an edit on a hook failure — and never go silent either: an
|
|
@@ -6551,6 +6613,24 @@ program
|
|
|
6551
6613
|
return fail("push failed — no upstream, offline, or nothing to push.");
|
|
6552
6614
|
console.log("✓ pushed the current branch to its remote.");
|
|
6553
6615
|
});
|
|
6616
|
+
// ---- footprint (how much text Hunch injects into an agent's context) -------
|
|
6617
|
+
program
|
|
6618
|
+
.command("footprint")
|
|
6619
|
+
.description("Measure how much text Hunch injects into an agent's context — MCP tool list, hunch_context brief, grounding block, hook text — from the same code paths the product serves. Tokens are estimated as characters / 4.")
|
|
6620
|
+
.option("--json", "emit the report (hunch.footprint/1) as JSON")
|
|
6621
|
+
.option("--target <file>", "file to measure the hunch_context result for (default: the file most decisions cite)")
|
|
6622
|
+
.action(async (opts) => {
|
|
6623
|
+
const report = await measureFootprint(findRoot(), { target: opts.target });
|
|
6624
|
+
if (opts.json)
|
|
6625
|
+
return console.log(JSON.stringify(report, null, 2));
|
|
6626
|
+
const width = Math.max(...report.surfaces.map((s) => s.id.length));
|
|
6627
|
+
for (const s of report.surfaces)
|
|
6628
|
+
console.log(`${s.id.padEnd(width)} ${String(s.chars).padStart(7)} ~${s.est_tokens}`);
|
|
6629
|
+
console.log("\nNot measured in-process:");
|
|
6630
|
+
for (const u of report.unmeasured)
|
|
6631
|
+
console.log(`· ${u}`);
|
|
6632
|
+
console.log(dim("\nTokens estimated as characters / 4, not a tokenizer."));
|
|
6633
|
+
});
|
|
6554
6634
|
// ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
|
|
6555
6635
|
program
|
|
6556
6636
|
.command("drift")
|
|
@@ -6607,10 +6687,13 @@ program
|
|
|
6607
6687
|
.command("grounding")
|
|
6608
6688
|
.description("Check the committed grounding docs (CLAUDE.md, AGENTS.md, copilot-instructions, hunch.mdc, hunch.md) against what the PUBLIC graph generates — direction-aware: counts that merely LAG a merge are reported, counts AHEAD of the store (a record never committed) or divergent prose fail. --refresh regenerates every existing doc from the public store (never the overlay union, never a doc the project lacks). Exits 1 on ahead/diverged unless refreshed.")
|
|
6609
6689
|
.option("--refresh", "regenerate the existing grounding docs from the public store (what the post-merge hook and the release remedy run)")
|
|
6690
|
+
.option("--force", "with --refresh: re-render even a block written by a newer Hunch template")
|
|
6610
6691
|
.option("--json", "machine-readable verdicts")
|
|
6611
6692
|
.option("--quiet", "print nothing on success")
|
|
6612
6693
|
.action((opts) => {
|
|
6613
6694
|
const root = findRoot();
|
|
6695
|
+
if (opts.force && !opts.refresh)
|
|
6696
|
+
console.error("hunch grounding: --force only applies with --refresh; checking without re-rendering.");
|
|
6614
6697
|
// PUBLIC-ONLY by construction, exactly as the release gate and the freshness test
|
|
6615
6698
|
// read it: HUNCH_PRIVATE_DIR at an empty overlay beats .hunch/local.json and the
|
|
6616
6699
|
// shared pointer, so a dev machine with an overlay attached can never write union
|
|
@@ -6631,7 +6714,8 @@ program
|
|
|
6631
6714
|
return { doc: rel, verdict: { kind: "diverged", reason: "no managed HUNCH block" } };
|
|
6632
6715
|
return { doc: rel, verdict: classifyGroundingBlock(committed, generated) };
|
|
6633
6716
|
});
|
|
6634
|
-
const refreshed = opts.refresh ? refreshExistingGrounding(root, store) : [];
|
|
6717
|
+
const refreshed = opts.refresh ? refreshExistingGrounding(root, store, { force: opts.force }) : [];
|
|
6718
|
+
// `newer` (a block from a newer Hunch template) is reported, never failing.
|
|
6635
6719
|
const failing = verdicts.filter((v) => v.verdict.kind === "ahead" || v.verdict.kind === "diverged");
|
|
6636
6720
|
const lagging = verdicts.filter((v) => v.verdict.kind === "lagging");
|
|
6637
6721
|
if (opts.json) {
|
|
@@ -6647,12 +6731,15 @@ program
|
|
|
6647
6731
|
continue;
|
|
6648
6732
|
if (v.verdict.kind === "fresh" && opts.quiet)
|
|
6649
6733
|
continue;
|
|
6650
|
-
console.log(`${v.verdict.kind === "fresh" ? "✓" : v.verdict.kind === "lagging" ? "·" : "✗"} ${describeGroundingFreshness(v.doc, v.verdict)}`);
|
|
6734
|
+
console.log(`${v.verdict.kind === "fresh" ? "✓" : v.verdict.kind === "lagging" || v.verdict.kind === "newer" ? "·" : "✗"} ${describeGroundingFreshness(v.doc, v.verdict)}`);
|
|
6651
6735
|
}
|
|
6652
6736
|
if (!opts.quiet && !failing.length) {
|
|
6737
|
+
const newer = verdicts.filter((v) => v.verdict.kind === "newer");
|
|
6653
6738
|
console.log(lagging.length
|
|
6654
6739
|
? `\n${lagging.length} doc(s) lag a merge — transient; the next capture commit or \`hunch grounding --refresh\` heals it.`
|
|
6655
|
-
:
|
|
6740
|
+
: newer.length
|
|
6741
|
+
? `\n${newer.length} doc(s) written by a newer Hunch — kept as written; upgrade Hunch to check them fully.`
|
|
6742
|
+
: "✓ grounding docs are fresh.");
|
|
6656
6743
|
}
|
|
6657
6744
|
}
|
|
6658
6745
|
if (!opts.refresh && failing.length)
|
|
@@ -7315,7 +7402,7 @@ program
|
|
|
7315
7402
|
else {
|
|
7316
7403
|
const team = readTeamConfig(root);
|
|
7317
7404
|
console.log(team
|
|
7318
|
-
? `overlay: off, but .hunch/team.json advertises the team store (${team.shared_repo}) — run \`hunch init\` to auto-connect`
|
|
7405
|
+
? `overlay: off, but .hunch/team.json advertises the team store (${team.shared_repo}) — ${isTeamStoreTrusted(root, team) ? "run \`hunch init\` to auto-connect" : "if it is your team's store, run \`hunch shared --trust\` to connect"}`
|
|
7319
7406
|
: dim(`private: off — run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
|
|
7320
7407
|
}
|
|
7321
7408
|
// Worktree posture: linked worktrees share ONE memory via the git common dir. Only
|
|
@@ -7389,10 +7476,6 @@ function fail(msg) {
|
|
|
7389
7476
|
process.exitCode = 1;
|
|
7390
7477
|
}
|
|
7391
7478
|
// --- agent-hook helpers (used by `hunch hook`) -----------------------------
|
|
7392
|
-
const HOOK_REMINDER = "Hunch (engineering memory) is available for this repo. Before editing, call " +
|
|
7393
|
-
"hunch_check_constraints(scope) for do-not-break invariants and hunch_why(target) " +
|
|
7394
|
-
"for the rationale; use hunch_get_dependents for blast radius and hunch_bug_lineage " +
|
|
7395
|
-
"for prior root causes. After a non-trivial choice, record it with hunch_record_decision.";
|
|
7396
7479
|
/** Read all of stdin (the hook event JSON). A TTY (no piped input) resolves to ""
|
|
7397
7480
|
* so an accidental interactive `hunch hook` exits cleanly instead of hanging. */
|
|
7398
7481
|
function readStdin() {
|
|
@@ -7419,6 +7502,171 @@ function toRepoRel(root, abs) {
|
|
|
7419
7502
|
const rel = repoRelativeTarget(abs, root);
|
|
7420
7503
|
return isAbsolute(rel) || /^[a-zA-Z]:/.test(rel) ? "" : rel;
|
|
7421
7504
|
}
|
|
7505
|
+
/** Files a shell command wrote get the grounding the edit tools would have
|
|
7506
|
+
* delivered before the edit — late, but while the agent can still revise.
|
|
7507
|
+
* Already-served grounding (a delta) is not repeated. */
|
|
7508
|
+
const MAX_SHELL_GROUNDED = 3;
|
|
7509
|
+
function shellWriteGrounding(root, store, provider, evt, written) {
|
|
7510
|
+
const parts = [];
|
|
7511
|
+
const grounded = [];
|
|
7512
|
+
for (const target of written.slice(0, MAX_SHELL_GROUNDED)) {
|
|
7513
|
+
const g = fileGrounding(root, store, provider, evt, target, join(root, target));
|
|
7514
|
+
if (g?.mode === "full") {
|
|
7515
|
+
parts.push(g.text);
|
|
7516
|
+
grounded.push(target);
|
|
7517
|
+
}
|
|
7518
|
+
}
|
|
7519
|
+
if (!parts.length)
|
|
7520
|
+
return "";
|
|
7521
|
+
const more = written.length > MAX_SHELL_GROUNDED ? ` (${written.length - MAX_SHELL_GROUNDED} more written file(s) not checked)` : "";
|
|
7522
|
+
const sibling = parts.some((p) => p.startsWith(SIBLING_HEADING)) ? " It starts with a fix a same-shaped function elsewhere received and this file's copy never did: resolve it before you finish." : "";
|
|
7523
|
+
return `Hunch: this shell command wrote ${grounded.join(", ")}${more}. Edits made outside the Edit/Write tools skip the pre-edit grounding, so it arrives now: re-check the change against it before relying on it.${sibling}\n\n${parts.join("\n\n")}`;
|
|
7524
|
+
}
|
|
7525
|
+
/** Memory budget beside a sibling lesson (the default is 1500 tokens). */
|
|
7526
|
+
const SIBLING_MEMORY_BUDGET_TOKENS = 800;
|
|
7527
|
+
/** The advisory grounding for one repo-relative file: the ranked memory slice,
|
|
7528
|
+
* retired code, doc anchors, recent tasks and sibling-fix lessons. `delta` when
|
|
7529
|
+
* identical grounding was already served this session, null when Hunch knows
|
|
7530
|
+
* nothing about the file. Serves the pre-edit hook and files a shell command
|
|
7531
|
+
* wrote (which never pass through the edit tools). */
|
|
7532
|
+
function fileGrounding(root, store, provider, evt, target, abs) {
|
|
7533
|
+
// Decision-grounding for PROSE (doc≠graph): a markdown target that declares
|
|
7534
|
+
// <!-- hunch:topic … --> anchors gets each topic's CURRENT decision — the
|
|
7535
|
+
// graph outranks the prose being edited, and a stale pin is called out inline.
|
|
7536
|
+
let docGround = "";
|
|
7537
|
+
if (/\.(md|mdx)$/i.test(target)) {
|
|
7538
|
+
try {
|
|
7539
|
+
docGround = renderDocGrounding(parseDocAnchors(readFileSync(abs, "utf8")), store.recs("decisions"));
|
|
7540
|
+
}
|
|
7541
|
+
catch { /* unreadable / not yet created — no doc grounding */ }
|
|
7542
|
+
}
|
|
7543
|
+
const ctx = store.assembleContext(target);
|
|
7544
|
+
// Sibling fixes: a same-shaped function elsewhere was fixed and this copy
|
|
7545
|
+
// never was — the concrete lesson a scoped constraint cannot carry.
|
|
7546
|
+
const siblings = siblingGrounding(root, target, store.recs("symbols"), ctx.constraints.filter((c) => c.severity === "blocking" && c.scope.some((g) => pathMatchesGlob(target, g))).map((c) => ({ id: c.id, statement: c.statement })));
|
|
7547
|
+
// Remember what was delivered, so the first check the agent runs can follow
|
|
7548
|
+
// up if the function is still untouched (pipeline.ts lessonReminder).
|
|
7549
|
+
if (siblings.lessons.length && evt.session_id && pipelineEnabled()) {
|
|
7550
|
+
try {
|
|
7551
|
+
savePipelineState(evt.session_id, onLessonsDelivered(loadPipelineState(evt.session_id), siblings.lessons.map((l) => ({
|
|
7552
|
+
id: `${l.file}:${l.symbol}~${l.siblingFile}:${l.sibling}`,
|
|
7553
|
+
file: l.file,
|
|
7554
|
+
symbol: l.symbol,
|
|
7555
|
+
sibling: l.sibling,
|
|
7556
|
+
siblingFile: l.siblingFile,
|
|
7557
|
+
change: l.commits.map((c) => `${c.sha.slice(0, 8)} ${c.subject}`).join("; "),
|
|
7558
|
+
callers: [...(siblings.callers.get(l.symbol) ?? [])],
|
|
7559
|
+
hash: functionBodyHash(root, l.file, l.symbol),
|
|
7560
|
+
}))));
|
|
7561
|
+
}
|
|
7562
|
+
catch { /* the reminder is a convenience; the lesson itself was delivered */ }
|
|
7563
|
+
}
|
|
7564
|
+
// Regression Guard (edit-time grounding): what an in-force decision retired
|
|
7565
|
+
// from this file. No diff exists yet, so this is context — "don't re-add X" —
|
|
7566
|
+
// not a block; the commit-time `hunch check` does the actual gating.
|
|
7567
|
+
const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
|
|
7568
|
+
const recentTasks = taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, hookReportTaskId(root, provider, evt), target, { excludeTargetDeliveries: true })), target);
|
|
7569
|
+
const hasContent = ctx.constraints.length ||
|
|
7570
|
+
ctx.decisions.length ||
|
|
7571
|
+
ctx.bugs.length ||
|
|
7572
|
+
ctx.blast_radius.length ||
|
|
7573
|
+
ctx.findings.length ||
|
|
7574
|
+
ctx.landscape?.resources.length ||
|
|
7575
|
+
ctx.landscape?.relationships.length ||
|
|
7576
|
+
retired.length ||
|
|
7577
|
+
recentTasks.length ||
|
|
7578
|
+
docGround ||
|
|
7579
|
+
siblings.text;
|
|
7580
|
+
if (!hasContent)
|
|
7581
|
+
return null;
|
|
7582
|
+
const supplements = [
|
|
7583
|
+
...(retired.length ? [{
|
|
7584
|
+
id: "retired-code",
|
|
7585
|
+
kind: "retired-code",
|
|
7586
|
+
priority: 200,
|
|
7587
|
+
text: `⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ")}.`,
|
|
7588
|
+
}] : []),
|
|
7589
|
+
...(docGround ? [{ id: "doc-grounding", kind: "doc-grounding", priority: 100, text: docGround }] : []),
|
|
7590
|
+
...recentTasks,
|
|
7591
|
+
];
|
|
7592
|
+
// A sibling lesson is the most specific thing Hunch knows about this edit:
|
|
7593
|
+
// it leads, and the ranked memory gets a smaller budget so it cannot bury it.
|
|
7594
|
+
const envelope = buildDeliveryEnvelope(siblings.text ? { ...ctx, budget_tokens: Math.min(ctx.budget_tokens, SIBLING_MEMORY_BUDGET_TOKENS) } : ctx, {
|
|
7595
|
+
profile: "builder",
|
|
7596
|
+
root,
|
|
7597
|
+
symbols: store.recs("symbols"),
|
|
7598
|
+
components: store.recs("components"),
|
|
7599
|
+
decisionCorpus: store.recs("decisions"),
|
|
7600
|
+
supplements,
|
|
7601
|
+
});
|
|
7602
|
+
// Outside the envelope's budget on purpose: the lesson is a code change, not a
|
|
7603
|
+
// one-line supplement, and memory records must not crowd it out.
|
|
7604
|
+
const text = [siblings.text, envelope.text.trim()].filter(Boolean).join("\n\n");
|
|
7605
|
+
// Identical grounding already shown this session → one-line delta instead of
|
|
7606
|
+
// the full 10-16KB block. Any record change re-sends the full text; the
|
|
7607
|
+
// strict-gate deny path above never routes through this (dec_244397d920).
|
|
7608
|
+
// Delivery receipts (dec_925f4bcaad): the ledger of what actually reached
|
|
7609
|
+
// an agent. A full injection is a serve; a delta one-liner attests the
|
|
7610
|
+
// earlier serve is still standing. Never throws, never blocks.
|
|
7611
|
+
const receipts = (event) => recordServed(root, [
|
|
7612
|
+
...envelope.delivered.map((item) => ({
|
|
7613
|
+
event,
|
|
7614
|
+
kind: item.kind,
|
|
7615
|
+
record_id: item.record_id,
|
|
7616
|
+
target,
|
|
7617
|
+
session_id: evt.session_id,
|
|
7618
|
+
rank: item.rank,
|
|
7619
|
+
delivery_reason: item.delivery_reason,
|
|
7620
|
+
provenance_status: item.provenance_status,
|
|
7621
|
+
token_cost: item.token_cost,
|
|
7622
|
+
delivery_profile: envelope.profile,
|
|
7623
|
+
ranking_policy: envelope.ranking_policy,
|
|
7624
|
+
})),
|
|
7625
|
+
// Delivered task lines are receipts too: they feed access-based recency.
|
|
7626
|
+
...envelope.supplements.filter((s) => s.kind === "recent-task" && s.delivered).map((s) => ({
|
|
7627
|
+
event, kind: "tasks", record_id: s.id, target, session_id: evt.session_id,
|
|
7628
|
+
rank: s.rank, delivery_reason: "supplemental", token_cost: s.token_cost,
|
|
7629
|
+
delivery_profile: envelope.profile, ranking_policy: envelope.ranking_policy,
|
|
7630
|
+
})),
|
|
7631
|
+
]);
|
|
7632
|
+
const reportTaskId = hookReportTaskId(root, provider, evt);
|
|
7633
|
+
// A new authoritative prompt gets its own full delivery. An earlier
|
|
7634
|
+
// prompt's session-level delta cannot establish this task's receipt.
|
|
7635
|
+
// A subagent reports to the prompt's task but starts with FRESH context:
|
|
7636
|
+
// it never saw that grounding, so its dedup is scoped by its own agent
|
|
7637
|
+
// identity (hashed — the raw agent_id is never retained in the key).
|
|
7638
|
+
const agentKey = evt.agent_id ? `:${reportHash(evt.agent_id).slice(7, 19)}` : "";
|
|
7639
|
+
// Dedup on the envelope's stable IDENTITY projection, never on the
|
|
7640
|
+
// rendered block: serving the full text writes delivery receipts, and the
|
|
7641
|
+
// next call's task ranking reads them back and moves the wording ("today"
|
|
7642
|
+
// → "delivered today"), so hashing the presentation made this grounding
|
|
7643
|
+
// self-invalidating and re-sent the full block for unchanged records.
|
|
7644
|
+
if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}${agentKey}`, text, deliveryDedupeInput(envelope, supplements) + (siblings.identity ? `\u0000sibling-fix\u0000${siblings.identity}` : "")) === "delta") {
|
|
7645
|
+
receipts("refreshed");
|
|
7646
|
+
return {
|
|
7647
|
+
mode: "delta",
|
|
7648
|
+
text: `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)${siblings.identity ? ", sibling-fix lesson" : ""} shown earlier — still current; hunch_why("${target}") to re-expand).`,
|
|
7649
|
+
};
|
|
7650
|
+
}
|
|
7651
|
+
receipts("served");
|
|
7652
|
+
let reportNotice = "";
|
|
7653
|
+
let recalled = null;
|
|
7654
|
+
if (reportTaskId) {
|
|
7655
|
+
try {
|
|
7656
|
+
const snapshots = snapshotDeliveredRecords(store, envelope);
|
|
7657
|
+
// The first time a lesson reaches this prompt's task, tell the USER in one
|
|
7658
|
+
// line (systemMessage); repeats of the same revision stay silent.
|
|
7659
|
+
recalled = reportPresentationEnabled(root) ? renderRecalledLine(unseenLessons(root, reportTaskId, snapshots)) : null;
|
|
7660
|
+
const occurrence = recordTaskDelivery(root, reportTaskId, envelope, snapshots, undefined, target);
|
|
7661
|
+
reportNotice = `\n\nHunch task ${reportTaskId} · delivery ${occurrence}. Inspect exact application references with hunch_report(task_id).`;
|
|
7662
|
+
}
|
|
7663
|
+
catch {
|
|
7664
|
+
reportNotice = "\n\nTask report observation unavailable; this delivery's task contribution remains unverified.";
|
|
7665
|
+
recalled = null;
|
|
7666
|
+
}
|
|
7667
|
+
}
|
|
7668
|
+
return { mode: "full", text: text + reportNotice, recalled };
|
|
7669
|
+
}
|
|
7422
7670
|
/** The in-repo files a pre-edit event would change, each with the lines the edit
|
|
7423
7671
|
* ADDS to that file. A Codex apply_patch yields one entry per touched path (a
|
|
7424
7672
|
* Move-to destination is its own entry, carrying the section's added lines);
|