@davesheffer/hunch 1.43.0 → 1.45.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 +16 -16
- package/dist/cli/index.js +267 -89
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +11 -0
- package/dist/core/changeProof.js +2 -1
- package/dist/core/commitrepair.d.ts +9 -3
- package/dist/core/commitrepair.js +23 -6
- package/dist/core/correction.d.ts +1 -1
- package/dist/core/correction.js +4 -3
- package/dist/core/docanchors.d.ts +11 -2
- package/dist/core/docanchors.js +232 -65
- package/dist/core/drift.d.ts +1 -1
- package/dist/core/drift.js +10 -3
- package/dist/core/eol.d.ts +2 -0
- package/dist/core/eol.js +6 -0
- package/dist/core/escalations.d.ts +20 -0
- package/dist/core/escalations.js +47 -0
- package/dist/core/hookText.js +1 -1
- package/dist/core/procstart.d.ts +15 -4
- package/dist/core/procstart.js +56 -18
- package/dist/core/runbookDraft.d.ts +7 -0
- package/dist/core/runbookDraft.js +43 -0
- package/dist/core/taskTouched.d.ts +2 -0
- package/dist/core/taskTouched.js +1 -1
- package/dist/extractors/git.d.ts +36 -6
- package/dist/extractors/git.js +86 -9
- package/dist/extractors/indexer.js +53 -14
- package/dist/extractors/k8sManifest.d.ts +31 -1
- package/dist/extractors/k8sManifest.js +133 -8
- package/dist/integrations/claudemd.d.ts +2 -0
- package/dist/integrations/claudemd.js +5 -3
- package/dist/integrations/gitignore.d.ts +23 -0
- package/dist/integrations/gitignore.js +48 -0
- package/dist/integrations/hooks.d.ts +15 -0
- package/dist/integrations/hooks.js +49 -1
- package/dist/integrations/mergeDriver.d.ts +2 -0
- package/dist/integrations/mergeDriver.js +2 -2
- package/dist/integrations/providers.d.ts +16 -0
- package/dist/integrations/providers.js +35 -6
- package/dist/integrations/scaffold.d.ts +5 -0
- package/dist/integrations/scaffold.js +13 -8
- package/dist/integrations/uninstall.d.ts +40 -0
- package/dist/integrations/uninstall.js +435 -0
- package/dist/mcp/leanToolList.d.ts +9 -0
- package/dist/mcp/leanToolList.js +89 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +62 -6
- package/dist/serve/mcpHttp.js +2 -0
- package/dist/serve/writelock.d.ts +7 -0
- package/dist/serve/writelock.js +30 -3
- package/dist/store/changeLedger.d.ts +5 -2
- package/dist/store/changeLedger.js +88 -37
- package/dist/store/hunchStore.js +4 -1
- package/package.json +3 -6
- package/server.json +2 -2
- package/tooling/md1-benchmark.mjs +1 -1
- package/tooling/competitive-watch.mjs +0 -142
package/dist/cli/index.js
CHANGED
|
@@ -43,7 +43,8 @@ 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,
|
|
46
|
+
import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeCommits, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
|
|
47
|
+
import { runbookDraft } from "../core/runbookDraft.js";
|
|
47
48
|
import { parseMemoryLog } from "../core/memorylog.js";
|
|
48
49
|
import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
|
|
49
50
|
import { orphanedCommitDecisions, planCommitRepair, repairDecisionCommit, pickRewrite, commitRepairReviewHash, mergeRewrites, firstFor, deadRewrites, resolvedRewriteIds, withoutDropped, addDropped, withheldForUnresolvableTo } from "../core/commitrepair.js";
|
|
@@ -59,15 +60,16 @@ import { installPostCommitHook, installPreCommitHook, installPostMergeHook, inst
|
|
|
59
60
|
import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
|
|
60
61
|
import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
|
|
61
62
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
62
|
-
import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade } from "../integrations/gitignore.js";
|
|
63
|
+
import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade, memoryIgnoreRule, describeMemoryIgnoreRule } from "../integrations/gitignore.js";
|
|
63
64
|
import { writeCiWorkflow } from "../integrations/ciAction.js";
|
|
65
|
+
import { applyUninstall, formatUninstallPlan, isOptedOut, OPT_OUT_FILE, optOutMessage, planUninstall } from "../integrations/uninstall.js";
|
|
64
66
|
import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
|
|
65
67
|
import { HOOK_REMINDER } from "../core/hookText.js";
|
|
66
68
|
import { measureFootprint } from "../core/footprint.js";
|
|
67
69
|
import { classifyGroundingBlock, describeGroundingFreshness } from "../core/groundingLag.js";
|
|
68
70
|
import { mergeGroundingFile } from "../core/groundingMerge.js";
|
|
69
71
|
import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
|
|
70
|
-
import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding, GROUNDING_DOC_PATHS } from "../integrations/providers.js";
|
|
72
|
+
import { scaffoldProviders, detectAssistants, PROVIDER_ASSISTANTS, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding, GROUNDING_DOC_PATHS } from "../integrations/providers.js";
|
|
71
73
|
import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
|
|
72
74
|
import { formatSearchHit, formatStructure } from "../core/format.js";
|
|
73
75
|
import { isStateKind, renderStateLine, stateSupplements } from "../core/stateDelivery.js";
|
|
@@ -92,7 +94,7 @@ import { isHumanConfirmed } from "../core/strictgate.js";
|
|
|
92
94
|
import { appendEvent, readEvents } from "../core/events.js";
|
|
93
95
|
import { computeStats, formatStats } from "../core/stats.js";
|
|
94
96
|
import { clearTaskSelection, injectionMode, loadTaskSelection, resetSessionInjections, saveTaskSelection, taskSelectionEnabled } from "../core/hookcache.js";
|
|
95
|
-
import { isBareFollowUp, isFilterableSelectionId, liveSelectionRecords, selectForTask } from "../core/taskSelection.js";
|
|
97
|
+
import { isBareFollowUp, isFilterableSelectionId, liveSelectionRecords, promptPaths, selectForTask } from "../core/taskSelection.js";
|
|
96
98
|
import { recordServed, servedSummary } from "../core/served.js";
|
|
97
99
|
import { readTaskReport, recordTaskDelivery, reportActivity, reportHash, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
|
|
98
100
|
import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
|
|
@@ -100,7 +102,7 @@ import { renderRecalledLine } from "../core/taskReportRender.js";
|
|
|
100
102
|
import { closeHookTask, hookReportTaskId, isNotificationPrompt, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
|
|
101
103
|
import { persistTaskRecord } from "../core/taskRecord.js";
|
|
102
104
|
import { recordHookObservation } from "../core/hookObservations.js";
|
|
103
|
-
import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
|
|
105
|
+
import { contextHookOutput, deliversPromptEvent, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
|
|
104
106
|
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";
|
|
105
107
|
import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
|
|
106
108
|
import { planAutoReview, planMutations } from "../core/autoreview.js";
|
|
@@ -116,7 +118,7 @@ import { countersignConstraint, countersignDecision } from "../core/countersign.
|
|
|
116
118
|
import { applyImportedAdrReview, carryImportedAdrReview, importedAdrReviewHash, importedAdrSourceHash, isImportedAdrDecision, pendingImportedAdrReviews } from "../core/importReview.js";
|
|
117
119
|
import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
|
|
118
120
|
import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
|
|
119
|
-
import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline } from "../core/escalations.js";
|
|
121
|
+
import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline, relevantEscalations } from "../core/escalations.js";
|
|
120
122
|
import { premiseEscalations } from "../core/premises.js";
|
|
121
123
|
import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
|
|
122
124
|
import { pathMatchesGlob } from "../core/glob.js";
|
|
@@ -218,6 +220,26 @@ registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
|
|
|
218
220
|
return { root, existing: store.captureHome(privateOnly) === "private"
|
|
219
221
|
? store.recs("constraints") : store.json.loadAll("constraints") };
|
|
220
222
|
});
|
|
223
|
+
// Opt-out gate (#449): a repository `hunch uninstall` opted out stays out. Setup
|
|
224
|
+
// and every other command refuse; the commands a leftover git or agent hook may
|
|
225
|
+
// still call exit 0 so they never break the user's commit, merge or agent turn
|
|
226
|
+
// (`hook` silently: its stdout is the agent's context). The merge drivers still
|
|
227
|
+
// run: a leftover driver that exits without writing %A would let git drop `theirs`.
|
|
228
|
+
const OPT_OUT_ALLOWED = new Set(["uninstall", "doctor", "help", "merge-driver", "merge-driver-grounding"]);
|
|
229
|
+
const OPT_OUT_QUIET = new Set(["hook", "sync", "check", "grounding", "repair-provenance", "workspaces"]);
|
|
230
|
+
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
231
|
+
let top = actionCommand;
|
|
232
|
+
while (top.parent && top.parent !== program)
|
|
233
|
+
top = top.parent;
|
|
234
|
+
const name = top.name();
|
|
235
|
+
if (OPT_OUT_ALLOWED.has(name))
|
|
236
|
+
return;
|
|
237
|
+
if (!isOptedOut(findRoot()))
|
|
238
|
+
return;
|
|
239
|
+
if (name !== "hook")
|
|
240
|
+
console.error(optOutMessage());
|
|
241
|
+
process.exit(OPT_OUT_QUIET.has(name) ? 0 : 1);
|
|
242
|
+
});
|
|
221
243
|
// Read an already-known update and schedule any registry refresh in a detached
|
|
222
244
|
// worker. No network handle is opened in this command's process, so the
|
|
223
245
|
// advisory cannot delay command completion or process exit.
|
|
@@ -332,6 +354,8 @@ program
|
|
|
332
354
|
.option("--private-sync", "post-commit synthesis writes captured decisions into the overlay repo (HUNCH_PRIVATE_DIR), never the public store")
|
|
333
355
|
.option("--shared-sync", "alias of --private-sync (for teams using one shared overlay repo for any code repo)")
|
|
334
356
|
.option("--no-auto-commit", "DON'T auto-commit captures (default: ON in every mode — the overlay repo is committed+pushed; the public .hunch/ is committed only and rides your next push)")
|
|
357
|
+
.option("--all-assistants", "configure every supported assistant, not only those found on this machine or in this repository")
|
|
358
|
+
.option("--details", "print every setup step and the per-assistant integration status")
|
|
335
359
|
.action((opts) => {
|
|
336
360
|
// Validate --firmness up front, before any side effects (indexing, git hooks,
|
|
337
361
|
// .mcp.json) or opening the store — a bad value must not leave a half-init.
|
|
@@ -345,32 +369,51 @@ program
|
|
|
345
369
|
// teammate/agent resolves the same single source of truth with zero manual setup.
|
|
346
370
|
const { store, teamWired } = openTeamStore(root);
|
|
347
371
|
const inv = resolveInvocation();
|
|
348
|
-
|
|
372
|
+
// Step lines are collected: the default output is a short summary with one next
|
|
373
|
+
// step; `--details` prints every step as written. Warnings always print.
|
|
374
|
+
const lines = [];
|
|
375
|
+
const say = (text) => { lines.push({ text, warn: text.includes("⚠") }); };
|
|
376
|
+
// A hook warning is followed by the snippet to add by hand: keep the group together.
|
|
377
|
+
const sayHook = (group, installed) => {
|
|
378
|
+
const warn = group.some((l) => l.includes("⚠"));
|
|
379
|
+
for (const text of group)
|
|
380
|
+
lines.push({ text, warn });
|
|
381
|
+
if (!warn)
|
|
382
|
+
summary.hooks.push(installed);
|
|
383
|
+
};
|
|
384
|
+
const summary = { index: "", hooks: [], assistants: ["Claude Code"], skipped: [] };
|
|
385
|
+
say(`🧠 Initializing Hunch at ${root}`);
|
|
349
386
|
store.json.ensureDirs(); // stamps the manifest at the current version when fresh
|
|
350
|
-
|
|
387
|
+
say(` ✓ .hunch/ scaffolded (schema v${readManifest(paths).schema_version})`);
|
|
351
388
|
if (teamWired)
|
|
352
|
-
|
|
389
|
+
say(` ✓ connected to the team's shared memory store (from .hunch/team.json) → ${teamWired}`);
|
|
353
390
|
// Exclude the derived SQLite index BEFORE it's written, so the working tree
|
|
354
391
|
// never goes dirty on the MCP server's index writes (which blocks branch
|
|
355
392
|
// switches). The .hunch/*.json graph stays tracked.
|
|
356
393
|
const gi = ensureGitignore(root);
|
|
357
394
|
if (gi.action !== "unchanged")
|
|
358
|
-
|
|
395
|
+
say(` ✓ .gitignore ${gi.action} (Hunch runtime index excluded)`);
|
|
359
396
|
// A repo migrated to a private overlay by an older release: bring its private-only
|
|
360
397
|
// block up to date (and stop publishing newly ignored memory dirs).
|
|
361
398
|
for (const line of describeGitignoreUpgrade(upgradeManagedGitignore(root)))
|
|
362
|
-
|
|
399
|
+
say(` ✓ ${line}`);
|
|
400
|
+
if (!store.privateDir) {
|
|
401
|
+
const ignored = memoryIgnoreRule(root);
|
|
402
|
+
if (ignored)
|
|
403
|
+
say(` ⚠ ${describeMemoryIgnoreRule(ignored)}`);
|
|
404
|
+
}
|
|
363
405
|
if (opts.index !== false) {
|
|
364
406
|
if (isGitRepo(root) && revExists("HEAD", root)) {
|
|
365
407
|
const res = indexRepo(store, root, { source: { kind: "commit", ref: "HEAD" } });
|
|
366
408
|
store.reindex();
|
|
367
|
-
|
|
409
|
+
say(` ✓ indexed committed HEAD (${res.files} files) → ${res.symbols} symbols, ${res.edges} edges, ${res.components} components`);
|
|
410
|
+
summary.index = `indexed committed HEAD (${res.files} files, ${res.symbols} symbols)`;
|
|
368
411
|
for (const item of res.coverage) {
|
|
369
412
|
const reasons = Object.entries(item.reasons).map(([reason, count]) => `${reason}:${count}`).join(", ");
|
|
370
|
-
|
|
413
|
+
say(` coverage ${item.language}: ${item.eligible} eligible · ${item.parsed} parsed · ${item.skipped} skipped${reasons ? ` (${reasons})` : ""}`);
|
|
371
414
|
}
|
|
372
415
|
if (res.skipped)
|
|
373
|
-
|
|
416
|
+
say(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
|
|
374
417
|
}
|
|
375
418
|
else {
|
|
376
419
|
// A checkout-derived graph can be swept into a later memory commit by
|
|
@@ -380,7 +423,7 @@ program
|
|
|
380
423
|
store.json.replaceAll("edges", mergeScannedEdges(store.json.loadAll("edges"), []));
|
|
381
424
|
store.json.replaceAll("components", []);
|
|
382
425
|
store.reindex();
|
|
383
|
-
|
|
426
|
+
say(" ⚠ skipped code graph: no committed HEAD — commit code, then run `hunch index`");
|
|
384
427
|
}
|
|
385
428
|
}
|
|
386
429
|
// Auto-commit is ON by default in every mode; --no-auto-commit persists the opt-out in
|
|
@@ -402,11 +445,11 @@ program
|
|
|
402
445
|
}
|
|
403
446
|
}
|
|
404
447
|
if (existing === null) {
|
|
405
|
-
|
|
448
|
+
say(` ⚠ refusing to rewrite ${localFile}: it is not a JSON object and may hold your private-overlay pointer. Fix or remove it, then re-run \`hunch init --no-auto-commit\`.`);
|
|
406
449
|
}
|
|
407
450
|
else {
|
|
408
451
|
writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
|
|
409
|
-
|
|
452
|
+
say(" ✓ local auto-commit preference OFF (an explicit hook --commit flag still takes precedence)");
|
|
410
453
|
}
|
|
411
454
|
}
|
|
412
455
|
// Collected so the linked-worktree note below can say what actually happened
|
|
@@ -424,21 +467,18 @@ program
|
|
|
424
467
|
}
|
|
425
468
|
const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: hookCommit, localOnly: syncToOverlay });
|
|
426
469
|
installs.push(h);
|
|
427
|
-
|
|
428
|
-
console.log(line);
|
|
470
|
+
sayHook(formatHookInstall(root, "post-commit hook", h, ` (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${hookCommit ? " — auto-commit on" : ""}`), hookCommit ? "post-commit (auto-commit on)" : "post-commit");
|
|
429
471
|
if (opts.autoCommit === false && hookReport(root).postCommit.flags?.includes("--commit")) {
|
|
430
|
-
|
|
472
|
+
say(" ⚠ the shared post-commit hook still auto-commits. To change it, re-run `hunch init --no-auto-commit` using a global install or the main checkout's Hunch.");
|
|
431
473
|
}
|
|
432
474
|
const pm = installPostMergeHook(root, inv.shell);
|
|
433
475
|
installs.push(pm);
|
|
434
|
-
|
|
435
|
-
console.log(line);
|
|
476
|
+
sayHook(formatHookInstall(root, "post-merge hook", pm, " (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)"), "post-merge");
|
|
436
477
|
const pc = installPostCheckoutHook(root, inv.shell);
|
|
437
478
|
installs.push(pc);
|
|
438
|
-
|
|
439
|
-
console.log(line);
|
|
479
|
+
sayHook(formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: records this machine's branches + worktrees on checkout)"), "post-checkout");
|
|
440
480
|
const m = installMergeDriver(root, inv.shell);
|
|
441
|
-
|
|
481
|
+
say(` ✓ team merge driver ${m.action}`);
|
|
442
482
|
// Auto-install the pre-commit guard by default (advisory: flags invariants
|
|
443
483
|
// touched directly OR via blast radius, never blocks). Opt out with
|
|
444
484
|
// --no-enforce; --enforce-strict makes blocking near/direct hits fail the commit.
|
|
@@ -446,12 +486,11 @@ program
|
|
|
446
486
|
const strict = !!opts.enforceStrict;
|
|
447
487
|
const p = installPreCommitHook(root, inv.shell, strict);
|
|
448
488
|
installs.push(p);
|
|
449
|
-
|
|
450
|
-
console.log(line);
|
|
489
|
+
sayHook(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"})`), `pre-commit guard (${strict ? "strict" : "advisory"})`);
|
|
451
490
|
}
|
|
452
491
|
}
|
|
453
492
|
else {
|
|
454
|
-
|
|
493
|
+
say(" ⚠ not a git repo — skipped hooks (run `git init` to enable the learning loop)");
|
|
455
494
|
}
|
|
456
495
|
// .mcp.json is the CANONICAL registration: Claude Code resolves it by file path,
|
|
457
496
|
// so it's immune to the Windows ~/.claude.json drive-letter case-split that a
|
|
@@ -459,18 +498,18 @@ program
|
|
|
459
498
|
// existing file is refused (con_8460b6770f) — warn and continue, never clobber.
|
|
460
499
|
try {
|
|
461
500
|
const mcp = writeMcpJson(root, inv.mcp);
|
|
462
|
-
|
|
501
|
+
say(` ✓ wrote ${rel(root, mcp)} (registers the Hunch MCP server — canonical, path-keyed; prefer over a global \`claude mcp add\`)`);
|
|
463
502
|
}
|
|
464
503
|
catch (e) {
|
|
465
|
-
|
|
504
|
+
say(` ⚠ skipped .mcp.json: ${e.message}`);
|
|
466
505
|
}
|
|
467
506
|
const cmds = writeSlashCommands(root);
|
|
468
|
-
|
|
507
|
+
say(` ✓ wrote ${cmds.written.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile, /capture, /heal, /audit)`);
|
|
469
508
|
for (const s of cmds.skipped) {
|
|
470
|
-
|
|
509
|
+
say(` ⚠ kept your existing ${rel(root, s)} (no hunch:generated marker — delete the file and re-run init to adopt Hunch's version)`);
|
|
471
510
|
}
|
|
472
511
|
const cmd = updateClaudeMd(root, store);
|
|
473
|
-
|
|
512
|
+
say(` ✓ updated ${rel(root, cmd)} with ambient Hunch context`);
|
|
474
513
|
// Firmness: stamp .hunch/config.json (default advisory) so `hunch hook` reads a
|
|
475
514
|
// level even before the user runs `hunch firmness` (--firmness validated above).
|
|
476
515
|
const firmness = writeConfig(paths, opts.firmness ? { firmness: opts.firmness } : {}).firmness;
|
|
@@ -478,18 +517,25 @@ program
|
|
|
478
517
|
// adapter reads firmness at run time, so changing it needs no config rewrite.
|
|
479
518
|
if (opts.agentHooks !== false) {
|
|
480
519
|
const a = installClaudeHooks(root, `${inv.agentHookShell} hook`);
|
|
481
|
-
|
|
520
|
+
say(` ✓ Claude Code agent hooks ${a.action} (firmness: ${firmness} — change with \`hunch firmness <level>\`)`);
|
|
482
521
|
}
|
|
483
522
|
// Multi-assistant compatibility: MCP + grounding + lifecycle adapters share
|
|
484
523
|
// the same .hunch/ graph across Cursor / VS Code / Windsurf / Antigravity.
|
|
485
524
|
if (opts.providers !== false) {
|
|
486
|
-
const
|
|
525
|
+
const detected = detectAssistants(root);
|
|
526
|
+
const only = opts.allAssistants || detected.size === 0 ? undefined : detected;
|
|
527
|
+
const ps = scaffoldProviders(root, inv.mcp, store, { agentHooks: opts.agentHooks !== false, only });
|
|
487
528
|
const ok = ps.filter((p) => !p.error);
|
|
529
|
+
summary.assistants.push(...ok.map((p) => p.assistant === "Any (AGENTS.md)" ? "any tool that reads AGENTS.md" : p.assistant));
|
|
530
|
+
if (only)
|
|
531
|
+
summary.skipped = PROVIDER_ASSISTANTS.filter((a) => !only.has(a));
|
|
532
|
+
if (summary.skipped.length)
|
|
533
|
+
say(` ✓ not configured (not found on this machine or in this repository): ${summary.skipped.join(", ")} — add them with \`hunch init --all-assistants\``);
|
|
488
534
|
const total = ok.reduce((a, p) => a + p.files.length, 0);
|
|
489
|
-
|
|
535
|
+
say(` ✓ wrote ${total} multi-assistant config file(s) → ${ok.map((p) => p.assistant).join(", ")}`);
|
|
490
536
|
for (const p of ps)
|
|
491
537
|
if (p.error)
|
|
492
|
-
|
|
538
|
+
say(` ⚠ skipped ${p.assistant}: ${p.error}`);
|
|
493
539
|
}
|
|
494
540
|
// Windows self-heal: if an earlier global `claude mcp add` left a drive-letter
|
|
495
541
|
// case-split in ~/.claude.json, merge it so hunch resolves under either casing.
|
|
@@ -499,16 +545,76 @@ program
|
|
|
499
545
|
// worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
|
|
500
546
|
// and note when we're initializing inside a linked worktree (memory is shared, not separate).
|
|
501
547
|
if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private", true)) {
|
|
502
|
-
|
|
548
|
+
say(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
|
|
503
549
|
}
|
|
504
550
|
for (const line of sharedHooksNote(root, installs))
|
|
505
|
-
|
|
551
|
+
say(line);
|
|
506
552
|
store.close();
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
553
|
+
if (opts.details) {
|
|
554
|
+
for (const l of lines)
|
|
555
|
+
console.log(l.text);
|
|
556
|
+
console.log("\n" + formatIntegrationHealth(inspectIntegrations(root)));
|
|
557
|
+
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`.");
|
|
558
|
+
console.log(reportActivity(root));
|
|
559
|
+
console.log("Cold start? Seed from history: hunch backfill --since 90d");
|
|
560
|
+
console.log("\n⭐ If Hunch earns its keep, a star helps others find it → https://github.com/davesheffer/hunch");
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
console.log(`🧠 Hunch is set up in ${root}`);
|
|
564
|
+
console.log(` ✓ memory: .hunch/${teamWired ? ` (connected to your team's shared store)` : ""}`);
|
|
565
|
+
if (summary.index)
|
|
566
|
+
console.log(` ✓ code index: ${summary.index}`);
|
|
567
|
+
if (summary.hooks.length)
|
|
568
|
+
console.log(` ✓ git hooks: ${summary.hooks.join(", ")}`);
|
|
569
|
+
console.log(` ✓ assistants: ${summary.assistants.join(", ")}`);
|
|
570
|
+
if (summary.skipped.length)
|
|
571
|
+
console.log(` not found here, so not configured: ${summary.skipped.join(", ")} (add with --all-assistants)`);
|
|
572
|
+
for (const l of lines)
|
|
573
|
+
if (l.warn)
|
|
574
|
+
console.log(l.text);
|
|
575
|
+
console.log("\nNext: reload your assistant, then ask it why a file you're about to change is built that way.");
|
|
576
|
+
console.log("No memory yet? Draft it from this repository's history: hunch backfill --since 90d");
|
|
577
|
+
console.log("More: hunch init --details (everything that was written) · hunch doctor (check the setup)");
|
|
578
|
+
});
|
|
579
|
+
// ---- uninstall ------------------------------------------------------------
|
|
580
|
+
program
|
|
581
|
+
.command("uninstall")
|
|
582
|
+
.description("Remove everything Hunch installed in this repository (instruction blocks, MCP entries, agent and git hooks, merge driver, slash commands, CI workflow) and opt the repository out so Hunch never sets itself up here again. Keeps .hunch/ (your engineering memory) unless --purge.")
|
|
583
|
+
.option("--dry-run", "print what would change and write nothing")
|
|
584
|
+
.option("--purge", "also delete .hunch/ (the engineering memory), the derived cache, Hunch's .gitignore blocks and this clone's pointer in the git dir")
|
|
585
|
+
.action((opts) => {
|
|
586
|
+
const root = findRoot();
|
|
587
|
+
const plan = planUninstall(root, { purge: !!opts.purge });
|
|
588
|
+
if (plan.refused.length) {
|
|
589
|
+
console.error(`Hunch uninstall at ${root}: nothing was changed — fix the file(s) below, then re-run.`);
|
|
590
|
+
for (const line of formatUninstallPlan(plan, !!opts.dryRun))
|
|
591
|
+
console.error(line);
|
|
592
|
+
process.exitCode = 1;
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const { applied, failed } = opts.dryRun ? { applied: plan.steps, failed: [] } : applyUninstall(plan);
|
|
596
|
+
// The header follows what actually ran: a failed opt-out marker stops
|
|
597
|
+
// everything, and saying "uninstalled" then would be a lie.
|
|
598
|
+
const header = opts.dryRun ? "Hunch uninstall (dry run — nothing written)"
|
|
599
|
+
: !failed.length ? "Hunch uninstalled"
|
|
600
|
+
: applied.some((s) => s.kind !== "keep") ? "Hunch partly uninstalled"
|
|
601
|
+
: "Hunch uninstall: nothing was changed";
|
|
602
|
+
console.log(`${header} at ${root}${opts.purge ? " (--purge)" : ""}`);
|
|
603
|
+
if (!plan.steps.some((s) => s.kind !== "keep"))
|
|
604
|
+
console.log(" nothing to remove — Hunch is not installed here");
|
|
605
|
+
for (const line of formatUninstallPlan({ ...plan, steps: applied }, !!opts.dryRun))
|
|
606
|
+
console.log(line);
|
|
607
|
+
if (failed.length) {
|
|
608
|
+
console.error(`
|
|
609
|
+
✗ ${failed.length} step(s) could not be applied — close running Hunch processes (\`hunch mcp\`, \`hunch serve\`, editors using them), then re-run \`hunch uninstall${opts.purge ? " --purge" : ""}\`:`);
|
|
610
|
+
for (const f of failed)
|
|
611
|
+
console.error(` ✗ ${f.path}: ${f.error}`);
|
|
612
|
+
process.exitCode = 1;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (!opts.dryRun && plan.steps.some((s) => s.path === OPT_OUT_FILE)) {
|
|
616
|
+
console.log(`\nCommit ${OPT_OUT_FILE} (and the removals) so every clone stays opted out. To reinstall later, delete ${OPT_OUT_FILE} and run \`hunch init\`.`);
|
|
617
|
+
}
|
|
512
618
|
});
|
|
513
619
|
// ---- index ----------------------------------------------------------------
|
|
514
620
|
program
|
|
@@ -1975,11 +2081,14 @@ program
|
|
|
1975
2081
|
store.close();
|
|
1976
2082
|
return fail(`base ref "${base ?? ""}" not found (range: ${range})`);
|
|
1977
2083
|
}
|
|
1978
|
-
|
|
1979
|
-
const
|
|
2084
|
+
// Hunch's own capture commits and store/grounding files are bookkeeping, not steps (#425).
|
|
2085
|
+
const commits = rangeCommits(base, root, head);
|
|
2086
|
+
const { steps, files } = runbookDraft(commits, rangeFiles(base, root, head));
|
|
1980
2087
|
if (!steps.length && !files.length) {
|
|
1981
2088
|
store.close();
|
|
1982
|
-
return fail(
|
|
2089
|
+
return fail(commits.length
|
|
2090
|
+
? `range ${range} only holds Hunch's own capture commits and files; nothing to draft`
|
|
2091
|
+
: `no commits or changes in range ${range}`);
|
|
1983
2092
|
}
|
|
1984
2093
|
const now = new Date().toISOString();
|
|
1985
2094
|
const rec = {
|
|
@@ -4109,7 +4218,7 @@ program
|
|
|
4109
4218
|
.description("Flag changes that touch a do-not-break invariant — the local guardrail AND the CI/PR Constraint Guard. Also flags (advisory) symbols you add that already exist elsewhere — possible re-implementation/sprawl.")
|
|
4110
4219
|
.option("--staged", "check git staged files (default)")
|
|
4111
4220
|
.option("--working", "check all working-tree edits vs HEAD (staged, unstaged, and untracked files)")
|
|
4112
|
-
.option("--commit <sha>", "check a specific commit's files")
|
|
4221
|
+
.option("--commit <sha>", "check a specific commit's files (judged alone, against its parent; use --base to gate a whole branch)")
|
|
4113
4222
|
.option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main) — for CI")
|
|
4114
4223
|
.option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
|
|
4115
4224
|
.option("--format <fmt>", "output: text (default) | markdown (a PR comment) | sarif (SARIF 2.1.0 for code scanning)", "text")
|
|
@@ -4125,11 +4234,14 @@ program
|
|
|
4125
4234
|
const sarif = opts.format === "sarif";
|
|
4126
4235
|
const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
|
|
4127
4236
|
const { store, root, teamPullStatus } = storeFor({ requireFreshTeamMemory: !!opts.strict && !opts.publicOnly });
|
|
4128
|
-
const teamFreshnessFailure = teamPullStatus !== null
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
? `strict check could not refresh the advertised team memory (${teamPullStatus}); refusing a stale pass`
|
|
4237
|
+
const teamFreshnessFailure = teamPullStatus !== null && !teamPullIsFresh(teamPullStatus)
|
|
4238
|
+
? `strict check could not refresh the advertised team memory (${teamPullStatus}); refusing a stale pass` +
|
|
4239
|
+
(teamPullStatus === "unconfigured" ? " — the shared memory remote has no published branch while this clone has memory; publish it with `hunch shared --sync`" : "")
|
|
4132
4240
|
: null;
|
|
4241
|
+
// An empty team remote is in sync with a never-committed clone, but it is also what
|
|
4242
|
+
// lost team memory looks like: never let that strict pass be silent (#427).
|
|
4243
|
+
if (teamPullStatus === "empty")
|
|
4244
|
+
console.error("· the advertised team memory has no published branch yet; this strict check ran with no team rules (publish with `hunch shared --sync` after the first capture)");
|
|
4133
4245
|
// Fail loudly on an unresolvable --base (e.g. CI forgot to fetch the base
|
|
4134
4246
|
// branch) — otherwise the diff is empty and the guard passes vacuously.
|
|
4135
4247
|
if (opts.base && !revExists(opts.base, root)) {
|
|
@@ -4195,13 +4307,14 @@ program
|
|
|
4195
4307
|
// blocking conformance predicate or an active policy guards.
|
|
4196
4308
|
// The COMPLETE diff (no synthesis byte budget); an incomplete one is flagged so
|
|
4197
4309
|
// content-matched blocking invariants fail closed instead of passing unseen.
|
|
4310
|
+
const freshAt = checkFreshnessRevision({ base: opts.base, commit: exactCommit }, root);
|
|
4198
4311
|
const gate = files.length
|
|
4199
4312
|
? (exactCommit ? commitGateDiff(exactCommit, root) : opts.base ? rangeGateDiff(opts.base, root) : opts.working ? workingGateDiff(root) : stagedGateDiff(root))
|
|
4200
4313
|
: { diff: "" };
|
|
4201
4314
|
const report = files.length
|
|
4202
4315
|
? store.buildCheckReport(files, gate.diff, {
|
|
4203
4316
|
strict: !!opts.strict,
|
|
4204
|
-
lastChange: (f) => lastChangeDate(f, root),
|
|
4317
|
+
lastChange: (f) => lastChangeDate(f, root, freshAt),
|
|
4205
4318
|
publicOnly: !!opts.publicOnly,
|
|
4206
4319
|
diffStatus: gate,
|
|
4207
4320
|
})
|
|
@@ -4342,7 +4455,8 @@ const vetoCmd = program
|
|
|
4342
4455
|
return;
|
|
4343
4456
|
}
|
|
4344
4457
|
const gate = opts.commit ? commitGateDiff(opts.commit, root) : opts.base ? rangeGateDiff(opts.base, root) : stagedGateDiff(root);
|
|
4345
|
-
const
|
|
4458
|
+
const freshAt = checkFreshnessRevision({ base: opts.base, commit: opts.commit }, root);
|
|
4459
|
+
const full = store.buildCheckReport(files, gate.diff, { strict: !!opts.strict, lastChange: (f) => lastChangeDate(f, root, freshAt), diffStatus: gate });
|
|
4346
4460
|
if (!full.vetoes.length) {
|
|
4347
4461
|
console.log(`✓ ${files.length} changed file(s) reverse no decision you rejected.`);
|
|
4348
4462
|
store.close();
|
|
@@ -4984,8 +5098,7 @@ program
|
|
|
4984
5098
|
const opened = openTeamStore(root, { requireFreshTeamMemory: firmness === "strict" });
|
|
4985
5099
|
store = opened.store;
|
|
4986
5100
|
// Same rule as the pre-edit path: strict never grounds from stale team rules.
|
|
4987
|
-
const stale = firmness === "strict" && opened.teamPullStatus
|
|
4988
|
-
&& opened.teamPullStatus !== "updated" && opened.teamPullStatus !== "current";
|
|
5101
|
+
const stale = firmness === "strict" && opened.teamPullStatus && !teamPullIsFresh(opened.teamPullStatus);
|
|
4989
5102
|
if (!stale)
|
|
4990
5103
|
shellGround = shellWriteGrounding(root, store, provider, evt, written);
|
|
4991
5104
|
}
|
|
@@ -5121,6 +5234,16 @@ program
|
|
|
5121
5234
|
}
|
|
5122
5235
|
}
|
|
5123
5236
|
catch { /* the next Stop or prompt retries */ }
|
|
5237
|
+
// Escalations this prompt's task touches (dec_12ba37db1c): asked once per
|
|
5238
|
+
// session, never answered for the human; the rest stay pending in
|
|
5239
|
+
// `hunch escalations`. A broken store must not take the prompt hook down.
|
|
5240
|
+
try {
|
|
5241
|
+
store ??= new HunchStore(paths);
|
|
5242
|
+
const ask = await promptEscalations(root, store, provider, evt);
|
|
5243
|
+
if (ask)
|
|
5244
|
+
parts.push(ask);
|
|
5245
|
+
}
|
|
5246
|
+
catch { /* escalations stay listed by `hunch escalations` */ }
|
|
5124
5247
|
// Pipeline turn bookkeeping (fresh block budget) + the one nag that must
|
|
5125
5248
|
// repeat: edits from an earlier turn still unverified.
|
|
5126
5249
|
if (evt.session_id && pipelineEnabled()) {
|
|
@@ -5282,36 +5405,17 @@ program
|
|
|
5282
5405
|
// further than a terminal.
|
|
5283
5406
|
const decisions = s.advisoryRecs("decisions");
|
|
5284
5407
|
const { pendingReview } = nowData(decisions, 3);
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
//
|
|
5288
|
-
//
|
|
5289
|
-
//
|
|
5290
|
-
|
|
5291
|
-
// out of session transcripts. Only the id and commit shas surface.
|
|
5292
|
-
const sessionStartQueue = readActivePendingRepairs(paths.root);
|
|
5293
|
-
escalations.push(...commitRepairEscalations(sessionStartQueue, decisions, s.recs("decisions"), withheldRewrites(paths.root, sessionStartQueue)));
|
|
5294
|
-
try {
|
|
5295
|
-
// Constitution human moments ride the same line; a broken policy store
|
|
5296
|
-
// must never take session-start orientation down (fail open). Public
|
|
5297
|
-
// store only — session transcripts travel further than a terminal.
|
|
5298
|
-
const { ConstitutionService: CS } = await import("../constitution/service.js");
|
|
5299
|
-
escalations.push(...policyEscalations(new CS(s, paths.root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
|
|
5300
|
-
}
|
|
5301
|
-
catch { /* constitution unavailable */ }
|
|
5302
|
-
// Only ACTIONABLE entries are worth asking inline — a duplicate-id
|
|
5303
|
-
// commit-repair follower whose own resolution says "act on a
|
|
5304
|
-
// different entry first" isn't a question the assistant can put to
|
|
5305
|
-
// the human directly (#61). Filtered BEFORE the bail check below so
|
|
5306
|
-
// "is there anything to say" and "what do we say" share one
|
|
5307
|
-
// predicate — an escalations list that's entirely non-actionable
|
|
5308
|
-
// must bail exactly like an empty one would.
|
|
5309
|
-
const actionableEsc = actionableEscalations(escalations);
|
|
5408
|
+
// Escalations are task-scoped (dec_12ba37db1c): session start has no task
|
|
5409
|
+
// yet, so it asks nothing when the provider delivers prompts — the prompt
|
|
5410
|
+
// hook asks about the ones a prompt touches; every entry stays pending in
|
|
5411
|
+
// `hunch escalations`. A provider with no prompt event would never ask,
|
|
5412
|
+
// so its orientation event keeps asking every actionable entry.
|
|
5413
|
+
const actionableEsc = deliversPromptEvent(provider) ? [] : (await actionableEscalationsWithAnchors(paths.root, s)).actionable;
|
|
5310
5414
|
if (!decisions.length && !actionableEsc.length) {
|
|
5311
5415
|
// Fresh graph and nothing else to raise: nothing to orient on, but
|
|
5312
5416
|
// the operating loop still ships. A queued commit-repair escalation
|
|
5313
|
-
// (checked against the full store
|
|
5314
|
-
//
|
|
5417
|
+
// (checked against the full store) is enough reason NOT to bail here
|
|
5418
|
+
// even when the visible decisions list is empty.
|
|
5315
5419
|
if (pipelineEnabled())
|
|
5316
5420
|
emitContext(provider, "SessionStart", [PIPELINE_LOOP, controllerBrief].filter(Boolean).join("\n\n"));
|
|
5317
5421
|
return;
|
|
@@ -5375,8 +5479,7 @@ program
|
|
|
5375
5479
|
// never grounding from public/stale memory (dec_77d99014e0).
|
|
5376
5480
|
const opened = openTeamStore(root, { requireFreshTeamMemory: firmness === "strict" });
|
|
5377
5481
|
store = opened.store;
|
|
5378
|
-
if (firmness === "strict" && opened.teamPullStatus
|
|
5379
|
-
&& opened.teamPullStatus !== "updated" && opened.teamPullStatus !== "current") {
|
|
5482
|
+
if (firmness === "strict" && opened.teamPullStatus && !teamPullIsFresh(opened.teamPullStatus)) {
|
|
5380
5483
|
// A strict deny is only trustworthy when it includes the latest team
|
|
5381
5484
|
// rules. Offline/busy/unconfigured team memory is unavailable, so the
|
|
5382
5485
|
// non-blocking hook says so instead of denying from stale state.
|
|
@@ -6323,8 +6426,11 @@ program
|
|
|
6323
6426
|
console.log(`Pruned ${deadEntries.length} dead queue entr${deadEntries.length === 1 ? "y" : "ies"} (decision moved on, has no commit on record, or was superseded/rejected): ${deadEntries.map((r) => r.id).join(", ")}`);
|
|
6324
6427
|
}
|
|
6325
6428
|
const candidates = rangeResolves && !opts.apply && !opts.drop ? mergeRangeChanges(oldRef, newRef, root) : [];
|
|
6326
|
-
|
|
6327
|
-
|
|
6429
|
+
// Orphaned means HEAD no longer reaches the commit either: a historical
|
|
6430
|
+
// --range tip alone would flag every newer, perfectly reachable commit (#450).
|
|
6431
|
+
const orphaned = candidates.length ? orphanedCommitDecisions(decisions, (sha) => commitRepairStatusAgainst(sha, [newRef, "HEAD"], root)) : [];
|
|
6432
|
+
const patchIds = orphaned.length ? commitPatchIds([...orphaned.map((d) => d.commit), ...candidates.map((c) => c.sha)], root) : new Map();
|
|
6433
|
+
const freshPlan = candidates.length ? planCommitRepair(orphaned, candidates, (sha) => patchIds.get(sha) ?? null) : { rewrites: [], records: [] };
|
|
6328
6434
|
// Detection always queues a fresh match — local-only, never committed, so
|
|
6329
6435
|
// it's safe to persist unconditionally. This is what makes running
|
|
6330
6436
|
// opportunistically (from the hook) worth anything: the match survives
|
|
@@ -6477,12 +6583,17 @@ program
|
|
|
6477
6583
|
console.log(unresolvedCount
|
|
6478
6584
|
? `Would repair ${toApply.length - unresolvedCount} of ${toApply.length} listed:`
|
|
6479
6585
|
: `Would repair ${toApply.length} commit reference(s):`);
|
|
6586
|
+
// Match strength, so a reviewer can tell an identical change from a
|
|
6587
|
+
// file-overlap guess before applying it into shared memory (#450).
|
|
6588
|
+
const previewPatchIds = commitPatchIds(toApply.flatMap((r) => [r.from, r.to]), root);
|
|
6480
6589
|
for (const r of toApply) {
|
|
6481
6590
|
const reason = reasonQueued(r);
|
|
6591
|
+
const samePatch = !!previewPatchIds.get(r.from) && previewPatchIds.get(r.from) === previewPatchIds.get(r.to);
|
|
6482
6592
|
const label = reason === "invisible" ? "not visible this run — --apply would leave it queued"
|
|
6483
6593
|
: reason === "withheld" ? "proposed replacement commit doesn't resolve in this repository — --apply would leave it queued"
|
|
6484
6594
|
: reason === "duplicate" ? "another queued entry for this same decision would be applied instead — --apply would leave this one queued"
|
|
6485
|
-
:
|
|
6595
|
+
: samePatch ? "same patch-id: identical change"
|
|
6596
|
+
: "file match only — check the diff before applying";
|
|
6486
6597
|
console.log(` ${r.id} ${r.from} → ${r.to}${label ? ` (${label})` : ""}`);
|
|
6487
6598
|
}
|
|
6488
6599
|
const reviewHash = commitRepairReviewHash(queue, withheldRewrites(root, queue));
|
|
@@ -7128,6 +7239,13 @@ program
|
|
|
7128
7239
|
console.log(`· ${f.id} — ${f.detail}`);
|
|
7129
7240
|
console.log(`\nHeal: this is a HUMAN call — check \`hunch repair-provenance\` for a queued match (the post-merge hook detects these automatically while the commit is still resolvable, but never applies one unattended); run \`hunch repair-provenance --apply\` to confirm it, or if the commit is already gone, manually correct the decision's provenance or leave it as historical record.\n`);
|
|
7130
7241
|
}
|
|
7242
|
+
const fenceUnclosed = kind("doc-fence-unclosed");
|
|
7243
|
+
if (fenceUnclosed.length) {
|
|
7244
|
+
console.log(`${fenceUnclosed.length} markdown file(s) open a code fence that never closes, hiding the topic markers below it:\n`);
|
|
7245
|
+
for (const f of fenceUnclosed)
|
|
7246
|
+
console.log(`· ${f.id} — ${f.detail}`);
|
|
7247
|
+
console.log(`\nHeal: close the fence (a line of the same \`\`\` or ~~~ run) where the example ends; the markers after it become live again.\n`);
|
|
7248
|
+
}
|
|
7131
7249
|
console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
|
|
7132
7250
|
}
|
|
7133
7251
|
finally {
|
|
@@ -7250,7 +7368,19 @@ program
|
|
|
7250
7368
|
.command("doctor")
|
|
7251
7369
|
.description("Diagnose the environment (git, synthesis provider, index freshness).")
|
|
7252
7370
|
.action(async () => {
|
|
7253
|
-
const
|
|
7371
|
+
const doctorRoot = findRoot();
|
|
7372
|
+
if (isOptedOut(doctorRoot)) {
|
|
7373
|
+
console.log(`Hunch: not installed (opted out — ${OPT_OUT_FILE} is present).`);
|
|
7374
|
+
const left = planUninstall(doctorRoot).steps.filter((s) => s.kind === "edit" || s.kind === "delete");
|
|
7375
|
+
if (left.length) {
|
|
7376
|
+
console.log(` ⚠ ${left.length} Hunch leftover(s) remain; \`hunch uninstall\` removes them:`);
|
|
7377
|
+
for (const s of left)
|
|
7378
|
+
console.log(` - ${s.path}`);
|
|
7379
|
+
}
|
|
7380
|
+
console.log(` To reinstall: delete ${OPT_OUT_FILE}, then run \`hunch init\`.`);
|
|
7381
|
+
return;
|
|
7382
|
+
}
|
|
7383
|
+
const integrations = inspectIntegrations(doctorRoot);
|
|
7254
7384
|
console.log(formatIntegrationHealth(integrations));
|
|
7255
7385
|
// A pin npm cannot serve kills every npx launcher (hooks and MCP) before Hunch
|
|
7256
7386
|
// runs, and the hosts report nothing. Name it here; bounded, offline-safe.
|
|
@@ -7406,6 +7536,9 @@ program
|
|
|
7406
7536
|
console.log(team
|
|
7407
7537
|
? `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"}`
|
|
7408
7538
|
: dim(`private: off — run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
|
|
7539
|
+
const ignored = memoryIgnoreRule(root);
|
|
7540
|
+
if (ignored)
|
|
7541
|
+
console.log(`memory: ⚠ ${describeMemoryIgnoreRule(ignored)}`);
|
|
7409
7542
|
}
|
|
7410
7543
|
// Worktree posture: linked worktrees share ONE memory via the git common dir. Only
|
|
7411
7544
|
// surfaced in a linked worktree (no noise in a normal single checkout), so a
|
|
@@ -7530,6 +7663,51 @@ function shellWriteGrounding(root, store, provider, evt, written) {
|
|
|
7530
7663
|
* A follow-up prompt widens the qualifying set rather than replacing it, so a
|
|
7531
7664
|
* "go" cannot unselect the task's memory. */
|
|
7532
7665
|
const TASK_SELECTION_TITLE_CHARS = 80;
|
|
7666
|
+
/** Every actionable escalation plus each entry id's anchor paths (a decision's
|
|
7667
|
+
* related_files, a policy's scope paths/globs). Titles come from the advisory
|
|
7668
|
+
* scope (public-only in private mode: session transcripts travel further than
|
|
7669
|
+
* a terminal); commit-repair liveness and the anchors read the full store, so a
|
|
7670
|
+
* private decision's repair stays answerable by id. */
|
|
7671
|
+
async function actionableEscalationsWithAnchors(root, store) {
|
|
7672
|
+
const decisions = store.advisoryRecs("decisions");
|
|
7673
|
+
const all = store.recs("decisions");
|
|
7674
|
+
const anchors = new Map(all.map((d) => [d.id, d.related_files]));
|
|
7675
|
+
const escalations = pendingEscalations(decisions);
|
|
7676
|
+
escalations.push(...premiseEscalations(decisions, { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
|
|
7677
|
+
const queue = readActivePendingRepairs(root);
|
|
7678
|
+
escalations.push(...commitRepairEscalations(queue, decisions, all, withheldRewrites(root, queue)));
|
|
7679
|
+
try {
|
|
7680
|
+
// Constitution human moments ride the same line; a broken policy store must
|
|
7681
|
+
// never take the hook down (fail open). Public store only.
|
|
7682
|
+
const { ConstitutionService: CS } = await import("../constitution/service.js");
|
|
7683
|
+
const policies = new CS(store, root).list({ publicOnly: true });
|
|
7684
|
+
for (const p of policies)
|
|
7685
|
+
anchors.set(p.id, p.scope.paths);
|
|
7686
|
+
escalations.push(...policyEscalations(policies.map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
|
|
7687
|
+
}
|
|
7688
|
+
catch { /* constitution unavailable */ }
|
|
7689
|
+
// Only ACTIONABLE entries are worth asking inline — a duplicate-id commit-repair
|
|
7690
|
+
// follower whose resolution says "act on a different entry first" isn't a
|
|
7691
|
+
// question the assistant can put to the human directly (#61).
|
|
7692
|
+
return { actionable: actionableEscalations(escalations), anchors };
|
|
7693
|
+
}
|
|
7694
|
+
/** The ASK line for the escalations one prompt touches, or "" (dec_12ba37db1c). */
|
|
7695
|
+
async function promptEscalations(root, store, provider, evt) {
|
|
7696
|
+
if (!evt.prompt || isNotificationPrompt(evt.prompt))
|
|
7697
|
+
return "";
|
|
7698
|
+
const { actionable, anchors } = await actionableEscalationsWithAnchors(root, store);
|
|
7699
|
+
if (!actionable.length)
|
|
7700
|
+
return "";
|
|
7701
|
+
// A qualifying record outside the top-K is still part of this task.
|
|
7702
|
+
const selection = loadTaskSelection(hookReportTaskId(root, provider, evt));
|
|
7703
|
+
const selectedIds = new Set([...(selection?.top ?? []), ...(selection?.qualifying ?? [])]);
|
|
7704
|
+
const relevant = relevantEscalations(actionable, { prompt: evt.prompt, paths: promptPaths(evt.prompt, root), selectedIds }, (id) => anchors.get(id) ?? []);
|
|
7705
|
+
// Each question is asked once per session, however the touched set changes.
|
|
7706
|
+
const unasked = relevant.filter((e) => injectionMode(evt.session_id, `prompt-escalation:${e.kind}:${e.topic}:${e.decisionIds.join(",")}`, e.question) === "full");
|
|
7707
|
+
if (!unasked.length)
|
|
7708
|
+
return "";
|
|
7709
|
+
return `⚖ ${unasked.length} decision(s) this task touches need the human's call — ASK the user inline, never decide for them: ${unasked.map((e) => e.question).join(" · ")}`;
|
|
7710
|
+
}
|
|
7533
7711
|
function promptTaskSelection(root, store, provider, evt) {
|
|
7534
7712
|
// A host notification is not a task prompt: the earlier selection stands untouched.
|
|
7535
7713
|
if (!taskSelectionEnabled() || isNotificationPrompt(evt.prompt))
|