@davesheffer/hunch 1.46.0 → 1.47.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +280 -358
- package/dist/cli/invocation.d.ts +8 -13
- package/dist/cli/invocation.js +26 -29
- package/dist/cli/taskReport.d.ts +6 -0
- package/dist/cli/taskReport.js +31 -8
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/correction.d.ts +2 -0
- package/dist/core/correction.js +23 -10
- package/dist/core/docanchors.js +9 -9
- package/dist/core/drift.js +22 -27
- package/dist/core/escalations.d.ts +16 -58
- package/dist/core/escalations.js +39 -121
- package/dist/core/glob.d.ts +7 -7
- package/dist/core/glob.js +12 -11
- package/dist/core/groundingLag.d.ts +2 -9
- package/dist/core/groundingLag.js +5 -11
- package/dist/core/groundingMerge.d.ts +2 -7
- package/dist/core/groundingMerge.js +35 -54
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/taskRecord.d.ts +1 -1
- package/dist/core/taskRecord.js +2 -2
- package/dist/core/taskReport.d.ts +8 -3
- package/dist/core/taskReport.js +7 -2
- package/dist/core/taskReportEvidence.d.ts +7 -0
- package/dist/core/taskReportEvidence.js +16 -4
- package/dist/core/taskReportHook.js +2 -2
- package/dist/core/taskReportPublic.js +2 -2
- package/dist/core/taskReportRender.js +2 -2
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/extractors/diff.d.ts +4 -4
- package/dist/extractors/diff.js +25 -29
- package/dist/extractors/git.d.ts +18 -23
- package/dist/extractors/git.js +72 -64
- package/dist/extractors/helm.d.ts +10 -0
- package/dist/extractors/helm.js +43 -59
- package/dist/extractors/indexer.d.ts +6 -0
- package/dist/extractors/indexer.js +126 -178
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +28 -37
- package/dist/extractors/languages.js +55 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +7 -13
- package/dist/extractors/parse.js +39 -51
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/health.js +1 -1
- package/dist/integrations/hooks.d.ts +9 -14
- package/dist/integrations/hooks.js +19 -28
- package/dist/integrations/mergeDriver.js +13 -14
- package/dist/integrations/probe.d.ts +11 -0
- package/dist/integrations/probe.js +29 -4
- package/dist/integrations/sync.d.ts +16 -4
- package/dist/integrations/sync.js +54 -3
- package/dist/integrations/workspaceLedger.d.ts +1 -1
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +71 -83
- package/dist/mcp/taskReportTools.js +4 -4
- package/dist/serve/app.js +2 -2
- package/dist/store/hunchStore.js +12 -18
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +67 -76
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +32 -49
- package/dist/taskReports.d.ts +1 -0
- package/dist/taskReports.js +2 -2
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -46,7 +46,7 @@ import { isParserLoadError, loadNativeTreeSitter } from "../extractors/nativeTre
|
|
|
46
46
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
47
47
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
48
48
|
import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
|
|
49
|
-
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, pullHunchStatusDetailed, describePullFailure, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
|
|
49
|
+
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, sameFilesystemEntry, commitAndPushHunch, pullHunchStatus, pullHunchStatusDetailed, describePullFailure, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
|
|
50
50
|
import { runbookDraft } from "../core/runbookDraft.js";
|
|
51
51
|
import { parseMemoryLog } from "../core/memorylog.js";
|
|
52
52
|
import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
|
|
@@ -61,7 +61,7 @@ import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStric
|
|
|
61
61
|
import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
|
|
62
62
|
import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, hookInvocationLines, formatHookInstall, sharedHooksNote } from "../integrations/hooks.js";
|
|
63
63
|
import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
|
|
64
|
-
import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
|
|
64
|
+
import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor, holdsOnDefaultBranch, HELD_ON_DEFAULT_BRANCH } from "../integrations/sync.js";
|
|
65
65
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
66
66
|
import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade, memoryIgnoreRule, describeMemoryIgnoreRule } from "../integrations/gitignore.js";
|
|
67
67
|
import { writeCiWorkflow } from "../integrations/ciAction.js";
|
|
@@ -210,7 +210,7 @@ registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
|
|
|
210
210
|
for (const record of records)
|
|
211
211
|
store.putCapture("constraints", record, privateOnly);
|
|
212
212
|
store.reindex();
|
|
213
|
-
if (home === "public" && !store
|
|
213
|
+
if (home === "public" && !publicFlushCommitsGrounding(store, root))
|
|
214
214
|
refreshExistingGrounding(root, store);
|
|
215
215
|
pumpMemoryHome(store, root, home, `hunch: capture ${records.length} sourced review rule(s)`);
|
|
216
216
|
}, (repository, privateOnly) => {
|
|
@@ -447,8 +447,25 @@ function storeFor(opts = {}) {
|
|
|
447
447
|
const { store, teamPullStatus, teamPullDetail } = openTeamStore(root, opts);
|
|
448
448
|
return { store, root, teamPullStatus, teamPullDetail };
|
|
449
449
|
}
|
|
450
|
+
/** Whether a public memory flush commits the tracked grounding docs itself. When it
|
|
451
|
+
* won't (auto-commit off, or held on the default branch), a by-hand command refreshes
|
|
452
|
+
* them on disk instead, so CLAUDE.md never keeps enforcing what the command just changed. */
|
|
453
|
+
function publicFlushCommitsGrounding(store, root) {
|
|
454
|
+
return store.autoCommit && !holdsOnDefaultBranch(hunchPaths(root).hunch);
|
|
455
|
+
}
|
|
456
|
+
// A held public flush says so once per command, on stderr: stdout may be a --json payload.
|
|
457
|
+
let heldNoticePrinted = false;
|
|
458
|
+
function noteHeld() {
|
|
459
|
+
if (heldNoticePrinted)
|
|
460
|
+
return;
|
|
461
|
+
heldNoticePrinted = true;
|
|
462
|
+
console.error(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
|
|
463
|
+
}
|
|
450
464
|
function pumpMemoryHome(store, root, home, message) {
|
|
451
|
-
flushMemoryHome(store, hunchPaths(root).hunch, home, message);
|
|
465
|
+
const flush = flushMemoryHome(store, hunchPaths(root).hunch, home, message);
|
|
466
|
+
if (flush === "held")
|
|
467
|
+
noteHeld();
|
|
468
|
+
return flush;
|
|
452
469
|
}
|
|
453
470
|
function pumpPolicyHome(store, root, service, policyId, message) {
|
|
454
471
|
const home = service.repository.homeOfPolicy(policyId);
|
|
@@ -457,7 +474,9 @@ function pumpPolicyHome(store, root, service, policyId, message) {
|
|
|
457
474
|
pumpMemoryHome(store, root, home, message);
|
|
458
475
|
}
|
|
459
476
|
function pumpMemoryHomes(store, root, homes, message) {
|
|
460
|
-
flushMemoryHomes(store, hunchPaths(root).hunch, homes, message);
|
|
477
|
+
const flushes = flushMemoryHomes(store, hunchPaths(root).hunch, homes, message);
|
|
478
|
+
if (flushes.public === "held")
|
|
479
|
+
noteHeld();
|
|
461
480
|
}
|
|
462
481
|
// ---- init -----------------------------------------------------------------
|
|
463
482
|
program
|
|
@@ -746,12 +765,11 @@ program
|
|
|
746
765
|
// never rewrites an existing one: `index` runs in CI/release gates on a clean
|
|
747
766
|
// checkout. Older blocks are upgraded by `hunch update` / init / private setup.
|
|
748
767
|
ensureGitignore(root, { upgradeExisting: false });
|
|
749
|
-
// The post-merge hook
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
// is not a setup command (it runs in CI, on any git repo), so it must
|
|
768
|
+
// The post-merge hook used to be installed only by init / private / shared, so a
|
|
769
|
+
// repo initialized before it existed never got it. `index` already self-heals the
|
|
770
|
+
// gitignore and does the same for the hooks, so upgrading needs no re-init. The
|
|
771
|
+
// heal is gated on post-commit already being present: `index` is not a setup
|
|
772
|
+
// command (it runs in CI on arbitrary repos) and so it must
|
|
755
773
|
// never be what FIRST hooks a repo that never ran init at all. A STALE
|
|
756
774
|
// post-commit block counts too (issue #315): it proves init ran, so the
|
|
757
775
|
// missing post-merge/post-checkout hooks are still this repo's to heal.
|
|
@@ -768,7 +786,7 @@ program
|
|
|
768
786
|
// while the docs are still Git-clean so it can stage them atomically with
|
|
769
787
|
// the graph. Pre-refreshing would make the safety filter treat generated
|
|
770
788
|
// docs as user-dirty and leave them stranded outside the memory commit.
|
|
771
|
-
const healed = shouldAutoCommit ? [] : refreshExistingGrounding(root, store);
|
|
789
|
+
const healed = shouldAutoCommit && publicFlushCommitsGrounding(store, root) ? [] : refreshExistingGrounding(root, store);
|
|
772
790
|
console.log(`Indexed ${res.files} files:`);
|
|
773
791
|
console.log(` ${counts.symbols} symbols, ${counts.edges} edges, ${counts.components} components`);
|
|
774
792
|
for (const item of res.coverage) {
|
|
@@ -825,9 +843,9 @@ program
|
|
|
825
843
|
const commits = logSince(opts.since, root, Number(opts.max));
|
|
826
844
|
const conc = Math.max(1, Math.min(16, Number(opts.concurrency) || 4));
|
|
827
845
|
console.log(`Backfilling from ${commits.length} commit(s) since ${opts.since} (concurrency ${conc})…`);
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
846
|
+
// One-time, best-effort context-window advisory: printed once before any commit is
|
|
847
|
+
// drafted (never per commit), and skipped under --deep since an ensemble may fan out
|
|
848
|
+
// to several workers with differing configurations.
|
|
831
849
|
if (!opts.deep && commits.length > 0) {
|
|
832
850
|
// { root }: the advisory must resolve the SAME provider the synthesis
|
|
833
851
|
// inside syncCommit resolves (persisted local.json preference included) —
|
|
@@ -835,7 +853,7 @@ program
|
|
|
835
853
|
// num_ctx warning for an actual Ollama run (issue #46).
|
|
836
854
|
const ctxProvider = await selectProvider({ root });
|
|
837
855
|
const ctxWarning = await maybeWarnOllamaContext(ctxProvider.name, process.env);
|
|
838
|
-
if (ctxWarning)
|
|
856
|
+
if (ctxWarning !== null)
|
|
839
857
|
console.log(ctxWarning);
|
|
840
858
|
}
|
|
841
859
|
let written = 0, skipped = 0, llm = 0, heuristic = 0;
|
|
@@ -996,7 +1014,13 @@ program
|
|
|
996
1014
|
// each home's artifacts into at most one memory commit.
|
|
997
1015
|
const publicCapture = r.status === "written" && !toOverlay;
|
|
998
1016
|
const privateCapture = r.status === "written" && toOverlay;
|
|
999
|
-
const
|
|
1017
|
+
const publicNeedsFlush = publicCapture || graphRefreshed || publicRepairTouched;
|
|
1018
|
+
// The default-branch hold covers the post-commit hook too. Its baked-in --commit is
|
|
1019
|
+
// the installed auto-commit default, so only a --commit typed by hand overrides it.
|
|
1020
|
+
// Decided before the grounding refresh: a held capture leaves tracked docs at HEAD,
|
|
1021
|
+
// so `git commit -a` cannot commit counts whose record it leaves untracked.
|
|
1022
|
+
const publicHeld = doCommit && publicNeedsFlush && (opts.fromHook || opts.commit === undefined) && holdsOnDefaultBranch(hunchPaths(root).hunch);
|
|
1023
|
+
const groundingToStage = publicNeedsFlush && !publicHeld ? refreshCommittableGrounding(root, store) : [];
|
|
1000
1024
|
if (!opts.fromHook && (r.status === "written" || graphRefreshed || publicRepairTouched)) {
|
|
1001
1025
|
const healed = refreshExistingGrounding(root, store);
|
|
1002
1026
|
const refreshed = [...new Set([...groundingToStage.map((file) => relative(root, file)), ...healed])];
|
|
@@ -1004,9 +1028,10 @@ program
|
|
|
1004
1028
|
console.log(` ↳ grounding refreshed: ${refreshed.join(", ")}`);
|
|
1005
1029
|
}
|
|
1006
1030
|
if (doCommit) {
|
|
1007
|
-
const publicNeedsFlush = publicCapture || graphRefreshed || publicRepairTouched;
|
|
1008
1031
|
const privateNeedsFlush = privateCapture || (privateCorrectionQueued && graphRefreshed) || g2Recorded > 0 || privateRepairTouched;
|
|
1009
|
-
|
|
1032
|
+
if (publicHeld && !opts.quiet)
|
|
1033
|
+
console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
|
|
1034
|
+
const publicResult = publicNeedsFlush && !publicHeld
|
|
1010
1035
|
? commitAndPushHunch(hunchPaths(root).hunch, publicCapture
|
|
1011
1036
|
? `hunch: capture ${r.decision?.id ?? "decision"}`
|
|
1012
1037
|
: graphRefreshed
|
|
@@ -1254,9 +1279,8 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
|
|
|
1254
1279
|
const backLink = commonDir ? separateGitDirLink(commonDir) : "";
|
|
1255
1280
|
const configuredHooks = includeHook ? hooksDir(root) : "";
|
|
1256
1281
|
const hookDir = configuredHooks ? (isAbsolute(configuredHooks) ? configuredHooks : join(root, configuredHooks)) : "";
|
|
1257
|
-
// Setup installs
|
|
1258
|
-
//
|
|
1259
|
-
// overlay is not a rollback.
|
|
1282
|
+
// Setup installs both hooks together, so both must be snapshotted: restoring only one
|
|
1283
|
+
// while the other still points at a deleted overlay is not a rollback.
|
|
1260
1284
|
const hookFiles = hookDir ? [join(hookDir, "post-commit"), join(hookDir, "post-merge")] : [];
|
|
1261
1285
|
const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer, backLink] : []), ...hookFiles];
|
|
1262
1286
|
const snapshots = new Map(paths.map((path) => [path, setupPathSnapshot(path)]));
|
|
@@ -1278,7 +1302,8 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
|
|
|
1278
1302
|
markGitignoreWrite: () => mark(codeGitignore),
|
|
1279
1303
|
markTeamWrite: () => mark(teamFile),
|
|
1280
1304
|
markSharedPointerWrite: () => { mark(backLink); mark(sharedPointer); },
|
|
1281
|
-
markHookWrite: () => hookFiles
|
|
1305
|
+
markHookWrite: () => { for (const f of hookFiles)
|
|
1306
|
+
mark(f); },
|
|
1282
1307
|
// Migration is a one-way ownership handoff. Once public records have been
|
|
1283
1308
|
// durably copied into this clone, a later setup failure may restore routing
|
|
1284
1309
|
// files but must not delete the clone that now holds their surviving copy.
|
|
@@ -2284,12 +2309,13 @@ program
|
|
|
2284
2309
|
}
|
|
2285
2310
|
}
|
|
2286
2311
|
store.reindex();
|
|
2287
|
-
|
|
2288
|
-
flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`);
|
|
2312
|
+
const flush = dec || con ? flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`) : null;
|
|
2289
2313
|
if (!intents.length)
|
|
2290
2314
|
console.log("No `hunch-why:` / `hunch-rule:` comments found.");
|
|
2291
2315
|
else
|
|
2292
2316
|
console.log(`✓ captured ${dec} decision(s) + ${con} constraint(s) from inline comments${opts.private ? " [private overlay]" : ""}`);
|
|
2317
|
+
if (flush === "held")
|
|
2318
|
+
console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
|
|
2293
2319
|
store.close();
|
|
2294
2320
|
});
|
|
2295
2321
|
// ---- conform (Architectural Conformance: does the code still satisfy recorded intent) ----
|
|
@@ -2331,7 +2357,7 @@ program
|
|
|
2331
2357
|
valid_from: now,
|
|
2332
2358
|
});
|
|
2333
2359
|
store.reindex();
|
|
2334
|
-
if (home === "public" && !store
|
|
2360
|
+
if (home === "public" && !publicFlushCommitsGrounding(store, root))
|
|
2335
2361
|
refreshExistingGrounding(root, store);
|
|
2336
2362
|
pumpMemoryHome(store, root, home, `hunch: record architectural invariant ${d.id}`);
|
|
2337
2363
|
console.log(`✓ recorded architectural invariant ${d.id}: "${opts.add}"`);
|
|
@@ -4048,7 +4074,7 @@ program
|
|
|
4048
4074
|
}
|
|
4049
4075
|
store.reindex();
|
|
4050
4076
|
const home = store.captureHome(!!opts.private);
|
|
4051
|
-
if (home === "public" && !store
|
|
4077
|
+
if (home === "public" && !publicFlushCommitsGrounding(store, root))
|
|
4052
4078
|
refreshExistingGrounding(root, store);
|
|
4053
4079
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: import ${decisions.length} ADR(s) from ${dir}`);
|
|
4054
4080
|
const live = decisions.filter((d) => d.status === "accepted").length;
|
|
@@ -4067,6 +4093,8 @@ program
|
|
|
4067
4093
|
}
|
|
4068
4094
|
if (flush === "pushed")
|
|
4069
4095
|
console.log(" ↳ private memory committed + pushed");
|
|
4096
|
+
if (flush === "held")
|
|
4097
|
+
console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
|
|
4070
4098
|
}
|
|
4071
4099
|
finally {
|
|
4072
4100
|
store.close();
|
|
@@ -4204,7 +4232,7 @@ program
|
|
|
4204
4232
|
store.reindex();
|
|
4205
4233
|
// Public grounding is a publishable artifact. Private rules stay local and
|
|
4206
4234
|
// are surfaced by local checks/MCP, never copied into committed agent docs.
|
|
4207
|
-
if (home === "public" && !store
|
|
4235
|
+
if (home === "public" && !publicFlushCommitsGrounding(store, root))
|
|
4208
4236
|
refreshExistingGrounding(root, store);
|
|
4209
4237
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${c.id}`);
|
|
4210
4238
|
console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})${opts.private ? " [private overlay]" : ""}`);
|
|
@@ -4218,6 +4246,8 @@ program
|
|
|
4218
4246
|
}
|
|
4219
4247
|
if (flush === "pushed")
|
|
4220
4248
|
console.log(" ↳ private memory committed + pushed");
|
|
4249
|
+
if (flush === "held")
|
|
4250
|
+
console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
|
|
4221
4251
|
store.close();
|
|
4222
4252
|
});
|
|
4223
4253
|
// ---- test (failure-learning loop) -----------------------------------------
|
|
@@ -4910,20 +4940,21 @@ program
|
|
|
4910
4940
|
.requiredOption("--by <new>", "decision id that supersedes it")
|
|
4911
4941
|
.action((oldId, opts) => {
|
|
4912
4942
|
const { store, root } = storeFor();
|
|
4913
|
-
//
|
|
4914
|
-
// that SAME store, or a private `by` linked against a public `old` would write the
|
|
4915
|
-
// private id straight into the committed public store.
|
|
4943
|
+
// The old decision's own memory home decides which store both ids must live in.
|
|
4916
4944
|
const home = decisionMemoryHome(store, oldId);
|
|
4917
|
-
|
|
4945
|
+
const priv = home === "private";
|
|
4946
|
+
// Check the old id first so a missing old decision is never blamed on --by.
|
|
4947
|
+
if (!store.decisionInStore(oldId, priv)) {
|
|
4918
4948
|
store.close();
|
|
4919
4949
|
return fail(`decision "${oldId}" not found`);
|
|
4920
4950
|
}
|
|
4921
|
-
|
|
4951
|
+
// Resolve --by in the SAME store: a private --by must never be linked into a committed public record.
|
|
4952
|
+
const by = store.decisionInStore(opts.by, priv);
|
|
4922
4953
|
if (!by) {
|
|
4923
4954
|
store.close();
|
|
4924
4955
|
return fail(`--by decision "${opts.by}" not found in the ${home} store that holds "${oldId}"`);
|
|
4925
4956
|
}
|
|
4926
|
-
const closed =
|
|
4957
|
+
const closed = priv ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
|
|
4927
4958
|
if (!closed) {
|
|
4928
4959
|
store.close();
|
|
4929
4960
|
return fail(`decision "${oldId}" cannot supersede itself`);
|
|
@@ -4960,7 +4991,7 @@ program
|
|
|
4960
4991
|
// is on, but no commit happens to carry that refresh when it's off, so do it here
|
|
4961
4992
|
// too — otherwise a retired constraint keeps showing as enforced on disk (the same
|
|
4962
4993
|
// gap record-constraint and `conform --add` close for their own capture path).
|
|
4963
|
-
if (home === "public" && !store
|
|
4994
|
+
if (home === "public" && !publicFlushCommitsGrounding(store, root))
|
|
4964
4995
|
refreshExistingGrounding(root, store);
|
|
4965
4996
|
// `--reason` goes in the commit BODY, never the subject: `hunch log`'s move
|
|
4966
4997
|
// classifier (src/core/memorylog.ts) regexes the commit SUBJECT for keywords
|
|
@@ -4968,11 +4999,14 @@ program
|
|
|
4968
4999
|
// in the subject could accidentally match one and misclassify the move.
|
|
4969
5000
|
const willCommit = home === "private" ? store.privateAutoCommit : store.autoCommit;
|
|
4970
5001
|
const message = `hunch: retire constraint ${id}${opts.reason ? `\n\n${opts.reason}` : ""}`;
|
|
4971
|
-
pumpMemoryHome(store, root, home, message);
|
|
5002
|
+
const flush = pumpMemoryHome(store, root, home, message);
|
|
4972
5003
|
console.log(`✓ ${retired.id} retired — window closed at ${retired.valid_to?.slice(0, 10)}.`);
|
|
4973
5004
|
if (opts.reason && !willCommit) {
|
|
4974
5005
|
console.log(" ⚠ --reason is not recorded anywhere: auto-commit is off, so no commit message captured it.");
|
|
4975
5006
|
}
|
|
5007
|
+
else if (opts.reason && flush === "held") {
|
|
5008
|
+
console.log(" ⚠ --reason is not recorded anywhere: the commit was held on the default branch, so no commit message captured it.");
|
|
5009
|
+
}
|
|
4976
5010
|
store.close();
|
|
4977
5011
|
});
|
|
4978
5012
|
// ---- firmness (agent-hook enforcement level) ------------------------------
|
|
@@ -5006,7 +5040,8 @@ program
|
|
|
5006
5040
|
.action(async (value) => {
|
|
5007
5041
|
const root = findRoot();
|
|
5008
5042
|
if (value != null) {
|
|
5009
|
-
const
|
|
5043
|
+
const trimmed = value.trim();
|
|
5044
|
+
const preference = normalizeProviderName(trimmed) || trimmed;
|
|
5010
5045
|
if (!SYNTH_PREFERENCES.includes(preference)) {
|
|
5011
5046
|
return fail(`provider must be one of: ${SYNTH_PREFERENCES.join(", ")}`);
|
|
5012
5047
|
}
|
|
@@ -5019,7 +5054,7 @@ program
|
|
|
5019
5054
|
console.log(`✓ local synthesis preference set to ${preference} (gitignored; it never changes a teammate's billing choice).`);
|
|
5020
5055
|
}
|
|
5021
5056
|
const resolution = await resolveSynthesisProvider({ root });
|
|
5022
|
-
const envValue = normalizeProviderName(process.env.HUNCH_SYNTH_PROVIDER
|
|
5057
|
+
const envValue = normalizeProviderName((process.env.HUNCH_SYNTH_PROVIDER ?? "").trim()) || undefined;
|
|
5023
5058
|
const local = readSynthesisPreference(root);
|
|
5024
5059
|
const hasValidEnv = !!envValue && SYNTH_PREFERENCES.includes(envValue);
|
|
5025
5060
|
console.log(`selected: ${resolution.provider.name} (${resolution.source})`);
|
|
@@ -5197,10 +5232,14 @@ program
|
|
|
5197
5232
|
if (!evt)
|
|
5198
5233
|
return;
|
|
5199
5234
|
eventName = evt.hook_event_name;
|
|
5200
|
-
|
|
5235
|
+
// Grounding reads the store of the checkout the event's file is in; the prompt's
|
|
5236
|
+
// task and the hook evidence stay with the process root that opened them.
|
|
5237
|
+
const processRoot = findRoot();
|
|
5238
|
+
const routed = hookEventRoot(processRoot, evt);
|
|
5239
|
+
const root = routed.root;
|
|
5201
5240
|
// The host delivered this event: runtime evidence for `hunch integrations check`,
|
|
5202
5241
|
// recorded before any policy decision so firmness never hides delivery itself.
|
|
5203
|
-
recordHookObservation(
|
|
5242
|
+
recordHookObservation(processRoot, provider, evt.hook_event_name, evt.tool_outcome?.status);
|
|
5204
5243
|
const paths = hunchPaths(root);
|
|
5205
5244
|
const firmness = readConfig(paths).firmness;
|
|
5206
5245
|
if (firmness === "off")
|
|
@@ -5224,7 +5263,7 @@ program
|
|
|
5224
5263
|
// Same rule as the pre-edit path: strict never grounds from stale team rules.
|
|
5225
5264
|
const stale = firmness === "strict" && opened.teamPullStatus && !teamPullIsFresh(opened.teamPullStatus);
|
|
5226
5265
|
if (!stale)
|
|
5227
|
-
shellGround = shellWriteGrounding(root, store, provider, evt, written);
|
|
5266
|
+
shellGround = shellWriteGrounding(root, store, provider, evt, written, processRoot);
|
|
5228
5267
|
}
|
|
5229
5268
|
}
|
|
5230
5269
|
else {
|
|
@@ -5536,8 +5575,8 @@ program
|
|
|
5536
5575
|
// so its orientation event keeps asking every actionable entry.
|
|
5537
5576
|
const actionableEsc = deliversPromptEvent(provider) ? [] : (await actionableEscalationsWithAnchors(paths.root, s)).actionable;
|
|
5538
5577
|
if (!decisions.length && !actionableEsc.length) {
|
|
5539
|
-
//
|
|
5540
|
-
//
|
|
5578
|
+
// A fresh graph with nothing to raise has nothing to orient on, but the
|
|
5579
|
+
// operating loop is still delivered. A queued commit-repair escalation
|
|
5541
5580
|
// (checked against the full store) is enough reason NOT to bail here
|
|
5542
5581
|
// even when the visible decisions list is empty.
|
|
5543
5582
|
if (pipelineEnabled())
|
|
@@ -5581,9 +5620,13 @@ program
|
|
|
5581
5620
|
return;
|
|
5582
5621
|
}
|
|
5583
5622
|
const targets = editTargets(root, evt.tool_input);
|
|
5584
|
-
// Nothing inside the repo → nothing for Hunch to say
|
|
5585
|
-
|
|
5623
|
+
// Nothing inside the repo → nothing for Hunch to say, except for a file in a
|
|
5624
|
+
// linked worktree of this repository Hunch could not ground (never silently).
|
|
5625
|
+
if (!targets.length) {
|
|
5626
|
+
if (routed.ungrounded)
|
|
5627
|
+
emitContext(provider, "PreToolUse", routed.ungrounded);
|
|
5586
5628
|
return;
|
|
5629
|
+
}
|
|
5587
5630
|
// Grounding below stays about the first in-repo file; the strict gate checks every target.
|
|
5588
5631
|
const { abs, target } = targets[0];
|
|
5589
5632
|
// A compiled red→green probe is only meaningful if its red receipt exists
|
|
@@ -5645,12 +5688,12 @@ program
|
|
|
5645
5688
|
: `Hunch: this patch is denied for ${denials.length} files (${denials.map((d) => d.target).join(", ")}).\n${denials.map((d) => d.hit.reason).join("\n")}`;
|
|
5646
5689
|
emitDeny(provider, reason);
|
|
5647
5690
|
for (const d of denials)
|
|
5648
|
-
observeHookDenial(
|
|
5691
|
+
observeHookDenial(processRoot, provider, evt, d.target, d.hit);
|
|
5649
5692
|
return;
|
|
5650
5693
|
}
|
|
5651
5694
|
}
|
|
5652
5695
|
// advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
|
|
5653
|
-
const grounded = fileGrounding(root, store, provider, evt, target, abs);
|
|
5696
|
+
const grounded = fileGrounding(root, store, provider, evt, target, abs, processRoot);
|
|
5654
5697
|
if (!grounded)
|
|
5655
5698
|
return; // no noise on files Hunch hasn't learned yet
|
|
5656
5699
|
emitContext(provider, "PreToolUse", grounded.text, grounded.mode === "full" ? grounded.recalled ?? undefined : undefined);
|
|
@@ -5829,7 +5872,7 @@ program
|
|
|
5829
5872
|
store.reindex();
|
|
5830
5873
|
if (home === "public") {
|
|
5831
5874
|
publicGroundingChanged = true;
|
|
5832
|
-
if (!store
|
|
5875
|
+
if (!publicFlushCommitsGrounding(store, root))
|
|
5833
5876
|
refreshExistingGrounding(root, store);
|
|
5834
5877
|
}
|
|
5835
5878
|
console.log(opts.approveImport
|
|
@@ -5889,7 +5932,7 @@ program
|
|
|
5889
5932
|
store.reindex();
|
|
5890
5933
|
if (home === "public") {
|
|
5891
5934
|
publicGroundingChanged = true;
|
|
5892
|
-
if (!store
|
|
5935
|
+
if (!publicFlushCommitsGrounding(store, root))
|
|
5893
5936
|
refreshExistingGrounding(root, store);
|
|
5894
5937
|
}
|
|
5895
5938
|
}
|
|
@@ -5908,7 +5951,7 @@ program
|
|
|
5908
5951
|
store.reindex();
|
|
5909
5952
|
if (home === "public") {
|
|
5910
5953
|
publicGroundingChanged = true;
|
|
5911
|
-
if (!store
|
|
5954
|
+
if (!publicFlushCommitsGrounding(store, root))
|
|
5912
5955
|
refreshExistingGrounding(root, store);
|
|
5913
5956
|
}
|
|
5914
5957
|
console.log(`✓ accepted ${opts.accept} (now ${source}, confidence 0.95${armed ? `, ${armed} tripwire(s) now blocking` : ""})`);
|
|
@@ -5967,7 +6010,7 @@ program
|
|
|
5967
6010
|
armedTotal += accepted.armed;
|
|
5968
6011
|
}
|
|
5969
6012
|
store.reindex();
|
|
5970
|
-
if (publicGroundingChanged && !store
|
|
6013
|
+
if (publicGroundingChanged && !publicFlushCommitsGrounding(store, root))
|
|
5971
6014
|
refreshExistingGrounding(root, store);
|
|
5972
6015
|
console.log(`✓ accepted ${ready.length} verified draft(s); ${armedTotal} tripwire(s) now blocking.`);
|
|
5973
6016
|
for (const it of ready)
|
|
@@ -6127,7 +6170,7 @@ program
|
|
|
6127
6170
|
}
|
|
6128
6171
|
if (accepted || rejected) {
|
|
6129
6172
|
store.reindex();
|
|
6130
|
-
if (publicAccepted && !store
|
|
6173
|
+
if (publicAccepted && !publicFlushCommitsGrounding(store, root))
|
|
6131
6174
|
refreshExistingGrounding(root, store);
|
|
6132
6175
|
pumpMemoryHomes(store, root, touchedHomes, "hunch: auto-review decision lifecycle");
|
|
6133
6176
|
}
|
|
@@ -6237,7 +6280,7 @@ program
|
|
|
6237
6280
|
// ---- escalations (the inline "ask the human" surface) ---------------------
|
|
6238
6281
|
program
|
|
6239
6282
|
.command("escalations")
|
|
6240
|
-
.description("The decisions a human must make
|
|
6283
|
+
.description("The decisions a human must make now — ask them inline in the prompt, never queued in the background. Captured memory auto-trusts on landing, so this lists only what the graph cannot resolve itself: topic conflicts, premise-stale decisions, one exact imported ADR at a time, a queued commit-provenance repair awaiting `--apply`, Constitution candidates awaiting review, and proposed policies whose activation is a human call. Normally empty; exits non-zero when any are open so an assistant or CI knows to raise them.")
|
|
6241
6284
|
.option("--json", "emit the escalation entries as JSON (the VS Code panel's data source)")
|
|
6242
6285
|
.action(async (opts) => {
|
|
6243
6286
|
const { store, root } = storeFor();
|
|
@@ -6247,8 +6290,8 @@ program
|
|
|
6247
6290
|
// Premise decay rides the same inline surface: a decision whose recorded
|
|
6248
6291
|
// reason died is a QUESTION for the human — authority never changes here.
|
|
6249
6292
|
items.push(...premiseEscalations(decisionsForEsc, { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
|
|
6250
|
-
const
|
|
6251
|
-
items.push(...commitRepairEscalations(
|
|
6293
|
+
const queuedRepairs = readActivePendingRepairs(root);
|
|
6294
|
+
items.push(...commitRepairEscalations(queuedRepairs, decisionsForEsc, decisionsForEsc, withheldRewrites(root, queuedRepairs)));
|
|
6252
6295
|
// Constitution moments ride the same inline surface (§59.5.3) — never a queue.
|
|
6253
6296
|
// Fail open: a broken policy store must not take the memory escalations down.
|
|
6254
6297
|
try {
|
|
@@ -6256,10 +6299,10 @@ program
|
|
|
6256
6299
|
items.push(...policyEscalations(new CS(store, root).list().map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
|
|
6257
6300
|
}
|
|
6258
6301
|
catch { /* constitution unavailable — memory escalations still surface */ }
|
|
6259
|
-
//
|
|
6260
|
-
// commit-repair follower whose own resolution says
|
|
6261
|
-
//
|
|
6262
|
-
//
|
|
6302
|
+
// The exit code and tallies gate only on the actionable subset: a duplicate-id
|
|
6303
|
+
// commit-repair follower whose own resolution says to act on a different entry
|
|
6304
|
+
// first still surfaces for transparency, but is not its own decision. --json
|
|
6305
|
+
// keeps the full list.
|
|
6263
6306
|
const actionable = actionableEscalations(items);
|
|
6264
6307
|
if (opts.json) {
|
|
6265
6308
|
console.log(JSON.stringify(items));
|
|
@@ -6271,18 +6314,12 @@ program
|
|
|
6271
6314
|
console.log("✓ Nothing needs your decision — memory is auto-trusted and self-consistent.");
|
|
6272
6315
|
return;
|
|
6273
6316
|
}
|
|
6274
|
-
//
|
|
6275
|
-
//
|
|
6276
|
-
//
|
|
6277
|
-
|
|
6278
|
-
// `firstFor`/dropTarget. Kept anyway (not assumed away) because that
|
|
6279
|
-
// guarantee lives in the PRODUCERS, not in `Escalation.actionable`'s own
|
|
6280
|
-
// contract, which a future producer could legitimately violate.
|
|
6281
|
-
console.log(escalationHeadline(items, "cli") + "\n");
|
|
6317
|
+
// The headline's "nothing actionable, N shown for context" fallback is currently
|
|
6318
|
+
// unreachable (every producer guarantees at least one actionable entry), but that
|
|
6319
|
+
// guarantee lives in the producers, not in Escalation.actionable's contract.
|
|
6320
|
+
console.log(`${escalationHeadline(items, "cli")}\n`);
|
|
6282
6321
|
for (const e of items) {
|
|
6283
|
-
// A non-actionable
|
|
6284
|
-
// transparency, but must not read like its own question — marked
|
|
6285
|
-
// distinctly so a skim doesn't mistake it for one of the tally above.
|
|
6322
|
+
// A non-actionable follower is shown for context only, so it gets a quieter marker.
|
|
6286
6323
|
console.log(` ${e.actionable === false ? "·" : "⚖"} ${e.question}`);
|
|
6287
6324
|
console.log(` ${dim(e.detail)}`);
|
|
6288
6325
|
console.log(` ${dim("→ " + e.resolution)}\n`);
|
|
@@ -6425,17 +6462,17 @@ program
|
|
|
6425
6462
|
store.close();
|
|
6426
6463
|
}
|
|
6427
6464
|
});
|
|
6428
|
-
// ---- repair-provenance (squash-merge commit
|
|
6465
|
+
// ---- repair-provenance (squash-merge commit-provenance self-repair) -------
|
|
6429
6466
|
program
|
|
6430
6467
|
.command("repair-provenance")
|
|
6431
6468
|
.description("Self-repair: detect a decision's commit provenance going orphaned by a squash-merge, matched by exact related_files overlap against the newly merged commit range — zero guessing beyond that. A fresh match not already rejected via --drop is queued (.hunch/pending-commit-repairs.json, local-only); --apply/--drop require the --expect hash printed by the preview or escalation; --only <dec_id> limits approval to one decision. Approval never scans for fresh matches. The post-merge hook runs detection automatically in the background but never passes --apply — the match signal isn't strong enough to trust an unattended write into shared team memory.")
|
|
6432
6469
|
.option("--apply", "apply the reviewed queued candidates (requires --expect; does not scan for new matches)")
|
|
6433
6470
|
.option("--expect <hash>", "exact queue review hash printed by the preview or escalation; required for --apply/--drop")
|
|
6434
|
-
.option("--only <dec_id>", "with --apply, rewrite only this decision id
|
|
6435
|
-
.option("--drop <dec_id>", "reject the queued match for this decision id
|
|
6471
|
+
.option("--only <dec_id>", "with --apply, rewrite only this decision id; everything else stays queued untouched")
|
|
6472
|
+
.option("--drop <dec_id>", "reject the queued match for this decision id without applying it; tombstoned so an identical future match for the same still-orphaned commit does not resurface (a genuinely different candidate still can)")
|
|
6436
6473
|
.option("--from-hook", "invoked by the git post-merge hook")
|
|
6437
6474
|
.option("--quiet", "minimal output")
|
|
6438
|
-
.option("--range <old..new>", "commit range to scan for replacement commits (default
|
|
6475
|
+
.option("--range <old..new>", "commit range to scan for replacement commits (default ORIG_HEAD..HEAD)")
|
|
6439
6476
|
.action((opts) => {
|
|
6440
6477
|
const { store, root } = storeFor();
|
|
6441
6478
|
try {
|
|
@@ -6450,41 +6487,32 @@ program
|
|
|
6450
6487
|
const parts = opts.range.split("..");
|
|
6451
6488
|
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
6452
6489
|
if (!opts.fromHook)
|
|
6453
|
-
fail(
|
|
6490
|
+
fail(`--range must look like "old..new"`);
|
|
6454
6491
|
return;
|
|
6455
6492
|
}
|
|
6456
|
-
|
|
6493
|
+
oldRef = parts[0];
|
|
6494
|
+
newRef = parts[1];
|
|
6457
6495
|
}
|
|
6458
6496
|
const rangeResolves = revExists(oldRef, root) && revExists(newRef, root);
|
|
6459
6497
|
if (!rangeResolves && opts.range) {
|
|
6460
|
-
// An explicitly-given range that doesn't resolve is a usage mistake, not
|
|
6461
|
-
// "no merge happened yet" — surface it. (The installed hook never passes
|
|
6462
|
-
// --range, so --from-hook never reaches this branch in practice.)
|
|
6463
6498
|
if (!opts.fromHook)
|
|
6464
6499
|
fail(`range "${oldRef}..${newRef}" does not resolve in this repository`);
|
|
6465
6500
|
return;
|
|
6466
6501
|
}
|
|
6502
|
+
// An unresolvable DEFAULT range just means no merge happened: detection is skipped below.
|
|
6467
6503
|
const decisions = store.recs("decisions");
|
|
6468
|
-
// One save path
|
|
6469
|
-
// queue semantics has exactly one write call to touch, not several
|
|
6470
|
-
// independently-reasoned ones. This file has twice shipped the same
|
|
6471
|
-
// failure class from that fragmentation: one write site's "is this
|
|
6472
|
-
// entry resolved" logic gets fixed while a sibling write site quietly
|
|
6473
|
-
// keeps reasoning about the queue differently, deleting an entry the
|
|
6474
|
-
// fixed site would have left alone.
|
|
6504
|
+
// One save path per file, so queue semantics cannot drift across write sites.
|
|
6475
6505
|
let queue = readPendingRepairs(root);
|
|
6476
|
-
const
|
|
6477
|
-
queue =
|
|
6506
|
+
const saveQueue = (next) => {
|
|
6507
|
+
queue = next;
|
|
6478
6508
|
writePendingRepairs(root, queue);
|
|
6479
6509
|
};
|
|
6480
|
-
// Tombstones
|
|
6481
|
-
//
|
|
6482
|
-
//
|
|
6483
|
-
// rejected. A genuinely different `to` for the same {id, from} is a new
|
|
6484
|
-
// proposal and is unaffected.
|
|
6510
|
+
// Tombstones hold the exact {id, from, to} triples a human rejected, so a later
|
|
6511
|
+
// merge re-deriving the identical match does not re-queue it; a different `to`
|
|
6512
|
+
// is a new proposal.
|
|
6485
6513
|
let dropped = readDroppedRepairs(root);
|
|
6486
6514
|
const saveDropped = (next) => {
|
|
6487
|
-
dropped =
|
|
6515
|
+
dropped = next;
|
|
6488
6516
|
writeDroppedRepairs(root, dropped);
|
|
6489
6517
|
};
|
|
6490
6518
|
// Review commands act only on the queue that was presented. Validate
|
|
@@ -6498,63 +6526,27 @@ program
|
|
|
6498
6526
|
if (opts.fromHook)
|
|
6499
6527
|
return fail("the post-merge hook may only detect repairs, never approve them.");
|
|
6500
6528
|
}
|
|
6501
|
-
//
|
|
6502
|
-
//
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
6507
|
-
// every load makes "never contains a rejected triple" a property of the
|
|
6508
|
-
// queue file itself, not of one write site. Same fragmentation lesson
|
|
6509
|
-
// as the comment on `save` above. Every OTHER reader of the queue
|
|
6510
|
-
// (escalations, SessionStart, the MCP tools) calls
|
|
6511
|
-
// readActivePendingRepairs instead of reading the raw file, so a
|
|
6512
|
-
// tombstoned entry can never surface as a question even before this
|
|
6513
|
-
// sweep next runs.
|
|
6514
|
-
//
|
|
6515
|
-
// Deletes unconditionally, without the prune's visibility caveats below
|
|
6516
|
-
// — safe here because the swept entry's exact triple is preserved in
|
|
6517
|
-
// dropped-commit-repairs.json, so nothing about it is unrecoverable
|
|
6518
|
-
// (unlike the prune, which could otherwise destroy the queue's only
|
|
6519
|
-
// durable record of a match).
|
|
6520
|
-
const activeInQueue = withoutDropped(queue, dropped);
|
|
6521
|
-
const swept = queue.filter((r) => !activeInQueue.includes(r));
|
|
6522
|
-
// Remembered for --only below, same reasoning as onlyWasPruned: targeting
|
|
6523
|
-
// an id whose only queued entry was just swept as already-rejected is not
|
|
6524
|
-
// the same usage error as targeting an id that never existed.
|
|
6525
|
-
const onlyWasSwept = !!opts.only && swept.some((r) => r.id === opts.only);
|
|
6529
|
+
// Sweep: entries whose exact triple is tombstoned. Deleted unconditionally since
|
|
6530
|
+
// the triple survives in the tombstone file.
|
|
6531
|
+
const activeQueue = withoutDropped(queue, dropped);
|
|
6532
|
+
const activeSet = new Set(activeQueue);
|
|
6533
|
+
const swept = queue.filter((r) => !activeSet.has(r));
|
|
6534
|
+
const onlySwept = !!opts.only && swept.some((r) => r.id === opts.only);
|
|
6526
6535
|
if (swept.length) {
|
|
6527
|
-
|
|
6536
|
+
saveQueue(activeQueue);
|
|
6528
6537
|
if (!opts.quiet)
|
|
6529
6538
|
console.log(`Swept ${swept.length} already-rejected match${swept.length === 1 ? "" : "es"} from the queue: ${swept.map((r) => r.id).join(", ")}`);
|
|
6530
6539
|
}
|
|
6531
|
-
// Prune
|
|
6532
|
-
//
|
|
6533
|
-
//
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6537
|
-
|
|
6538
|
-
|
|
6539
|
-
// actually-dead entry that outlives any number of --only runs
|
|
6540
|
-
// targeting other ids, without risking a false prune. Deferred until
|
|
6541
|
-
// after --range validation so a pure usage error changes nothing.
|
|
6542
|
-
const deadEntries = deadRewrites(queue, decisions);
|
|
6543
|
-
// Remembered for --only below: targeting an id that was pruned THIS
|
|
6544
|
-
// run is not the same usage error as targeting an id that never
|
|
6545
|
-
// existed — the human's target was real and is now resolved, not
|
|
6546
|
-
// unrecognized.
|
|
6547
|
-
const onlyWasPruned = !!opts.only && deadEntries.some((r) => r.id === opts.only);
|
|
6548
|
-
if (deadEntries.length) {
|
|
6549
|
-
// Object identity, not id — a corrupted queue file can carry a dead
|
|
6550
|
-
// entry and a still-live one sharing an id (#53); deadRewrites
|
|
6551
|
-
// already preserves identity via a plain filter, so pruning by id
|
|
6552
|
-
// here would destroy the live sibling right alongside its dead
|
|
6553
|
-
// namesake instead of leaving it for repairDecisionCommit to apply.
|
|
6554
|
-
const deadSet = new Set(deadEntries);
|
|
6555
|
-
save(queue.filter((r) => !deadSet.has(r)));
|
|
6540
|
+
// Prune: proven-dead entries only (never ones whose decision is merely absent this run).
|
|
6541
|
+
// Removed by object identity, not id: a corrupted queue may hold a dead and a live
|
|
6542
|
+
// entry sharing an id, and the live sibling must survive.
|
|
6543
|
+
const dead = deadRewrites(queue, decisions);
|
|
6544
|
+
const onlyPruned = !!opts.only && dead.some((r) => r.id === opts.only);
|
|
6545
|
+
if (dead.length) {
|
|
6546
|
+
const deadSet = new Set(dead);
|
|
6547
|
+
saveQueue(queue.filter((r) => !deadSet.has(r)));
|
|
6556
6548
|
if (!opts.quiet)
|
|
6557
|
-
console.log(`Pruned ${
|
|
6549
|
+
console.log(`Pruned ${dead.length} dead queue ${dead.length === 1 ? "entry" : "entries"} (decision moved on, has no commit on record, or was superseded/rejected): ${dead.map((r) => r.id).join(", ")}`);
|
|
6558
6550
|
}
|
|
6559
6551
|
const candidates = rangeResolves && !opts.apply && !opts.drop ? mergeRangeChanges(oldRef, newRef, root) : [];
|
|
6560
6552
|
// Orphaned means HEAD no longer reaches the commit either: a historical
|
|
@@ -6562,82 +6554,42 @@ program
|
|
|
6562
6554
|
const orphaned = candidates.length ? orphanedCommitDecisions(decisions, (sha) => commitRepairStatusAgainst(sha, [newRef, "HEAD"], root)) : [];
|
|
6563
6555
|
const patchIds = orphaned.length ? commitPatchIds([...orphaned.map((d) => d.commit), ...candidates.map((c) => c.sha)], root) : new Map();
|
|
6564
6556
|
const freshPlan = candidates.length ? planCommitRepair(orphaned, candidates, (sha) => patchIds.get(sha) ?? null) : { rewrites: [], records: [] };
|
|
6565
|
-
//
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
// next merge, so a human can confirm it later with `--apply` alone.
|
|
6570
|
-
// A match whose exact {id, from, to} triple was already tombstoned by
|
|
6571
|
-
// an earlier --drop is filtered out first — the human already rejected
|
|
6572
|
-
// exactly this proposed replacement, and a later merge re-deriving it
|
|
6573
|
-
// independently isn't a reason to ask again. A different `to` for the
|
|
6574
|
-
// same still-orphaned commit is a new proposal and passes through.
|
|
6575
|
-
const freshRewrites = withoutDropped(freshPlan.rewrites, dropped);
|
|
6576
|
-
if (freshRewrites.length)
|
|
6577
|
-
save(mergeRewrites(freshRewrites, queue));
|
|
6557
|
+
// Always persisted (local-only file): a hook-time detection survives for a later human --apply.
|
|
6558
|
+
const freshMatches = withoutDropped(freshPlan.rewrites, dropped);
|
|
6559
|
+
if (freshMatches.length)
|
|
6560
|
+
saveQueue(mergeRewrites(freshMatches, queue));
|
|
6578
6561
|
if (opts.drop) {
|
|
6579
6562
|
const before = queue.length;
|
|
6580
|
-
// firstFor is the single first-match-by-id rule — the same one
|
|
6581
|
-
// pickRewrite and mergeRewrites go through (#53, #56, #58) — to
|
|
6582
|
-
// decide which same-id entry would actually apply. A corrupted
|
|
6583
|
-
// queue file can carry two entries sharing an id, and dropping by
|
|
6584
|
-
// id would destroy the untargeted sibling too, with no tombstone
|
|
6585
|
-
// recording what it was. Filtering by object identity (not
|
|
6586
|
-
// `r.id !== opts.drop`) removes only the one entry actually
|
|
6587
|
-
// dropped; any other entry sharing the id is untouched and stays
|
|
6588
|
-
// queued.
|
|
6589
6563
|
const target = firstFor(queue, opts.drop);
|
|
6590
|
-
|
|
6591
|
-
// Tombstone only when something real was actually queued for this id —
|
|
6592
|
-
// an id that was never queued has no {from, to} to record, and
|
|
6593
|
-
// "nothing queued" is a usage-mistake signal that shouldn't quietly
|
|
6594
|
-
// create a tombstone file.
|
|
6564
|
+
saveQueue(queue.filter((r) => r !== target));
|
|
6595
6565
|
if (target) {
|
|
6596
6566
|
saveDropped(addDropped([{ id: target.id, from: target.from, to: target.to }], dropped));
|
|
6597
|
-
//
|
|
6598
|
-
|
|
6599
|
-
// run just saved above — the load-time sweep near the top of this
|
|
6600
|
-
// action already promises the queue file can never carry a
|
|
6601
|
-
// tombstoned entry, so re-run it here. Without this, --apply later
|
|
6602
|
-
// in this same invocation would happily write the exact rewrite
|
|
6603
|
-
// the human just rejected (#53 follow-up).
|
|
6604
|
-
save(withoutDropped(queue, dropped));
|
|
6567
|
+
// Re-sweep so an identical sibling cannot be applied later in this invocation.
|
|
6568
|
+
saveQueue(withoutDropped(queue, dropped));
|
|
6605
6569
|
}
|
|
6606
6570
|
if (!opts.quiet) {
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
6571
|
+
if (queue.length === before && !target)
|
|
6572
|
+
console.log(`Nothing queued or matched for "${opts.drop}" to drop.`);
|
|
6573
|
+
else
|
|
6574
|
+
console.log(`Dropped "${opts.drop}" from the queue — the same match won't resurface on its own; a genuinely different candidate for this decision still can.`);
|
|
6610
6575
|
}
|
|
6611
6576
|
if (!opts.apply)
|
|
6612
6577
|
return;
|
|
6613
6578
|
}
|
|
6614
|
-
// Deliberately
|
|
6615
|
-
// onlyWasPruned/onlyWasSwept below: when the prune or the sweep just
|
|
6616
|
-
// emptied the queue entirely, --only's own explanatory messages never
|
|
6617
|
-
// get a chance to fire — this generic message covers that case too,
|
|
6618
|
-
// and still exits 0 either way, so no usage-error bug survives here.
|
|
6579
|
+
// Deliberately before the --only messages.
|
|
6619
6580
|
if (!queue.length) {
|
|
6620
6581
|
if (!opts.quiet)
|
|
6621
6582
|
console.log("✓ Nothing to repair — no orphaned commit reference matched unambiguously, and nothing queued from an earlier run.");
|
|
6622
6583
|
return;
|
|
6623
6584
|
}
|
|
6624
|
-
// --only restricts the apply (or dry-run preview) to one decision id,
|
|
6625
|
-
// leaving every other queued/matched candidate exactly as it was — the
|
|
6626
|
-
// escalation asks a per-decision question, so the answer surface should
|
|
6627
|
-
// let a human accept one without also accepting everything else queued.
|
|
6628
6585
|
const toApply = opts.only ? queue.filter((r) => r.id === opts.only) : queue;
|
|
6629
6586
|
if (opts.only && !toApply.length) {
|
|
6630
|
-
if (
|
|
6631
|
-
// Not a usage error either: the human already rejected exactly this
|
|
6632
|
-
// match via an earlier --drop, and this run's sweep just confirmed
|
|
6633
|
-
// the rejection still holds — nothing left to apply.
|
|
6587
|
+
if (onlySwept) {
|
|
6634
6588
|
if (!opts.quiet)
|
|
6635
6589
|
console.log(`"${opts.only}" was already rejected via --drop — nothing left to do.`);
|
|
6636
6590
|
return;
|
|
6637
6591
|
}
|
|
6638
|
-
if (
|
|
6639
|
-
// Not a usage error: the human's target was real, and this run
|
|
6640
|
-
// already resolved it above (see the prune's own message for why).
|
|
6592
|
+
if (onlyPruned) {
|
|
6641
6593
|
if (!opts.quiet)
|
|
6642
6594
|
console.log(`"${opts.only}" was pruned earlier in this run — nothing left to do.`);
|
|
6643
6595
|
return;
|
|
@@ -6646,79 +6598,32 @@ program
|
|
|
6646
6598
|
fail(`no queued or matched entry for "${opts.only}"`);
|
|
6647
6599
|
return;
|
|
6648
6600
|
}
|
|
6649
|
-
//
|
|
6650
|
-
// that isn't can't actually be resolved by --apply (see
|
|
6651
|
-
// resolvedRewriteIds below), so dry-run/apply output must say so
|
|
6652
|
-
// instead of listing it as though --apply would act on it. Same
|
|
6653
|
-
// reasoning applies to a `to` that doesn't resolve to a real commit
|
|
6654
|
-
// here (issue #48): never write an unverified `to` into a decision's
|
|
6655
|
-
// commit field, and never claim --apply would either. Computed before
|
|
6656
|
-
// the dry-run branch below so the preview and the real run agree on
|
|
6657
|
-
// what --apply can actually do. `null` from commitsExist means the
|
|
6658
|
-
// check itself failed to run (not a git repo, git missing, timeout)
|
|
6659
|
-
// and is treated as fail-open — same discipline as drift.ts's own use
|
|
6660
|
-
// of commitsExist, though the asymmetry is worth naming: drift.ts only
|
|
6661
|
-
// decides what to REPORT on a `null`, while this gates a WRITE that
|
|
6662
|
-
// gets auto-committed into shared team memory. Fail-open is still the
|
|
6663
|
-
// right call here: `isGitRepo(root)` already passed above, so `null`
|
|
6664
|
-
// in practice means the batched `cat-file` call itself timed out —
|
|
6665
|
-
// rare, and a corrupted `to` that slips through is fully recoverable
|
|
6666
|
-
// (the entry survives durably in the queue either way; a bad write
|
|
6667
|
-
// is a revert). Fail-closed's cost — treating every entry as withheld
|
|
6668
|
-
// on a transient git error, indefinitely, on every run until the
|
|
6669
|
-
// error clears — is the worse failure mode to default to.
|
|
6601
|
+
// Apply-ability is computed before the dry-run branch so preview and apply agree.
|
|
6670
6602
|
const visibleIds = new Set(decisions.map((d) => d.id));
|
|
6671
|
-
|
|
6672
|
-
|
|
6603
|
+
// A null existence result is fail-open (treated as existing): isGitRepo already
|
|
6604
|
+
// passed, so null is a rare timeout, and a bad write is recoverable whereas
|
|
6605
|
+
// failing closed would withhold every entry indefinitely.
|
|
6606
|
+
const { applicable, withheld } = withheldForUnresolvableTo(toApply, commitsExist(toApply.map((r) => r.to), root));
|
|
6607
|
+
const applicableSet = new Set(applicable);
|
|
6673
6608
|
const withheldSet = new Set(withheld);
|
|
6674
|
-
// The plan --apply would act on if it ran right now — built here, before
|
|
6675
|
-
// the dry-run branch, so the preview reasons about the exact same plan
|
|
6676
|
-
// repairDecisionCommit would. `pickRewrite` (src/core/commitrepair.ts, a
|
|
6677
|
-
// plan-scoped `firstFor`) decides which entry wins when `applicable`
|
|
6678
|
-
// carries two sharing a decision id (a corrupted queue file, a hand
|
|
6679
|
-
// edit): first match, by construction. `wasChosen` asks that same
|
|
6680
|
-
// question by object identity rather than re-deriving it, so this file
|
|
6681
|
-
// and commitrepair.ts can never independently drift on which entry
|
|
6682
|
-
// "won" (#51, #58).
|
|
6683
6609
|
const plan = { rewrites: applicable, records: [...new Set(applicable.map((r) => r.id))] };
|
|
6684
|
-
const
|
|
6685
|
-
const
|
|
6686
|
-
// Whether an entry will still be sitting in the queue after this run,
|
|
6687
|
-
// and why — computed ONCE and shared by the dry-run preview, the queue
|
|
6688
|
-
// sweep, and the post-apply "left queued" report, so the three can
|
|
6689
|
-
// never independently drift out of sync about the same entry (they
|
|
6690
|
-
// did, twice, across two review rounds, before this was unified). The
|
|
6691
|
-
// sweep walks the FULL `queue`, not just `toApply` — under --only, an
|
|
6692
|
-
// untargeted id's entries never entered `applicable` at all, so
|
|
6693
|
-
// `wasChosen` would read "not chosen" for them too. Gating "duplicate"
|
|
6694
|
-
// on `applicableSet.has(r)` keeps the label meaning what it says: a
|
|
6695
|
-
// SIBLING actually competed for this exact id and lost, not merely
|
|
6696
|
-
// "this id wasn't this run's `applicable` set for some other reason."
|
|
6697
|
-
//
|
|
6698
|
-
// Precedence, most to least eclipsing: invisible, then withheld, then
|
|
6699
|
-
// duplicate. Invisible and withheld can each resolve on their own (a
|
|
6700
|
-
// later run sees the decision, or its `to` starts existing) or never
|
|
6701
|
-
// will (withheld, permanently, unless dropped) — "duplicate" is neither:
|
|
6702
|
-
// it means a SIBLING entry for the same id already won and got applied,
|
|
6703
|
-
// which repairDecisionCommit's own from-mismatch bail already makes true
|
|
6704
|
-
// by the time anything downstream would check it, so it's only worth
|
|
6705
|
-
// naming for entries that are otherwise resolvable.
|
|
6706
|
-
const reasonQueued = (r) => !visibleIds.has(r.id) ? "invisible"
|
|
6610
|
+
const isChosen = (r) => pickRewrite(plan, r.id) === r;
|
|
6611
|
+
const stayReason = (r) => !visibleIds.has(r.id) ? "invisible"
|
|
6707
6612
|
: withheldSet.has(r) ? "withheld"
|
|
6708
|
-
: applicableSet.has(r) && !
|
|
6613
|
+
: applicableSet.has(r) && !isChosen(r) ? "duplicate"
|
|
6709
6614
|
: null;
|
|
6710
|
-
const staysQueued = (r) =>
|
|
6615
|
+
const staysQueued = (r) => stayReason(r) !== null;
|
|
6711
6616
|
if (!opts.apply) {
|
|
6712
6617
|
if (!opts.quiet) {
|
|
6713
|
-
const
|
|
6714
|
-
console.log(
|
|
6715
|
-
? `Would repair ${toApply.length -
|
|
6618
|
+
const stayingCount = toApply.filter(staysQueued).length;
|
|
6619
|
+
console.log(stayingCount > 0
|
|
6620
|
+
? `Would repair ${toApply.length - stayingCount} of ${toApply.length} listed:`
|
|
6716
6621
|
: `Would repair ${toApply.length} commit reference(s):`);
|
|
6717
6622
|
// Match strength, so a reviewer can tell an identical change from a
|
|
6718
6623
|
// file-overlap guess before applying it into shared memory (#450).
|
|
6719
6624
|
const previewPatchIds = commitPatchIds(toApply.flatMap((r) => [r.from, r.to]), root);
|
|
6720
6625
|
for (const r of toApply) {
|
|
6721
|
-
const reason =
|
|
6626
|
+
const reason = stayReason(r);
|
|
6722
6627
|
const samePatch = !!previewPatchIds.get(r.from) && previewPatchIds.get(r.from) === previewPatchIds.get(r.to);
|
|
6723
6628
|
const label = reason === "invisible" ? "not visible this run — --apply would leave it queued"
|
|
6724
6629
|
: reason === "withheld" ? "proposed replacement commit doesn't resolve in this repository — --apply would leave it queued"
|
|
@@ -6736,7 +6641,7 @@ program
|
|
|
6736
6641
|
}
|
|
6737
6642
|
return;
|
|
6738
6643
|
}
|
|
6739
|
-
const
|
|
6644
|
+
const homes = new Set();
|
|
6740
6645
|
const appliedIds = new Set();
|
|
6741
6646
|
for (const d of decisions) {
|
|
6742
6647
|
const healed = repairDecisionCommit(d, plan);
|
|
@@ -6744,47 +6649,25 @@ program
|
|
|
6744
6649
|
continue;
|
|
6745
6650
|
const home = decisionMemoryHome(store, d.id);
|
|
6746
6651
|
store.putWhereItLives("decisions", healed);
|
|
6747
|
-
|
|
6652
|
+
homes.add(home);
|
|
6748
6653
|
appliedIds.add(d.id);
|
|
6749
6654
|
}
|
|
6750
|
-
//
|
|
6751
|
-
//
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
// or whose replacement doesn't exist, is not proof of anything, so it
|
|
6756
|
-
// stays queued rather than being swept up just because it was in
|
|
6757
|
-
// `toApply`. This also covers --only: toApply is just the one targeted
|
|
6758
|
-
// id, so an invisible, unresolvable, or beaten-out target is left
|
|
6759
|
-
// queued instead of deleted unresolved.
|
|
6760
|
-
//
|
|
6761
|
-
// `staysQueued` (OBJECT identity for both the withheld half, via
|
|
6762
|
-
// `withheldSet.has(r)`, and the duplicate half, via `wasChosen`) is
|
|
6763
|
-
// OR'd in ahead of the id-keyed `!resolvedIds.has(r.id)`: a corrupted
|
|
6764
|
-
// queue file could carry two entries sharing an id — one resolvable,
|
|
6765
|
-
// one not, or both resolvable — and an id-keyed check alone would treat
|
|
6766
|
-
// the pair as one unit, deleting/misreporting whichever entry didn't
|
|
6767
|
-
// actually get applied (#48, #51).
|
|
6768
|
-
const resolvedIds = resolvedRewriteIds(applicable, decisions);
|
|
6769
|
-
save(queue.filter((r) => staysQueued(r) || !resolvedIds.has(r.id)));
|
|
6655
|
+
// Invisible / unresolvable / beaten-out entries are never deleted just because they
|
|
6656
|
+
// were targeted. Identity-keyed checks come first so same-id pairs are handled per entry.
|
|
6657
|
+
const resolved = resolvedRewriteIds(applicable, decisions);
|
|
6658
|
+
const keep = (r) => staysQueued(r) || !resolved.has(r.id);
|
|
6659
|
+
saveQueue(queue.filter(keep));
|
|
6770
6660
|
if (!appliedIds.size) {
|
|
6771
|
-
//
|
|
6772
|
-
//
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
// fires unconditionally for every visible match) — reaching here
|
|
6776
|
-
// means every targeted candidate stayed queued (invisible or
|
|
6777
|
-
// withheld, reported independently so neither reason disappears).
|
|
6778
|
-
const invisibleIds = toApply.filter((r) => reasonQueued(r) === "invisible").map((r) => r.id);
|
|
6779
|
-
const withheldIds = toApply.filter((r) => reasonQueued(r) === "withheld").map((r) => r.id);
|
|
6661
|
+
// Counted per entry (not per distinct id), the same way the post-apply
|
|
6662
|
+
// "left queued" summary below counts, so two same-id entries read as two.
|
|
6663
|
+
const invisibleIds = toApply.filter((r) => stayReason(r) === "invisible").map((r) => r.id);
|
|
6664
|
+
const withheldIds = toApply.filter((r) => stayReason(r) === "withheld").map((r) => r.id);
|
|
6780
6665
|
if (!opts.quiet) {
|
|
6781
6666
|
if (invisibleIds.length) {
|
|
6782
6667
|
console.log(`Nothing applied — none of the targeted decision(s) are visible this run (branch checkout, or a private overlay not mounted?): ${invisibleIds.join(", ")}. Left queued for a run where they are — or \`hunch repair-provenance --drop <dec_id>\` if a decision is gone for good.`);
|
|
6783
6668
|
}
|
|
6784
6669
|
if (withheldIds.length) {
|
|
6785
|
-
const subject = withheldIds.length === 1
|
|
6786
|
-
? "an entry whose proposed replacement commit doesn't"
|
|
6787
|
-
: "entries whose proposed replacement commits don't";
|
|
6670
|
+
const subject = withheldIds.length === 1 ? "an entry whose proposed replacement commit doesn't" : "entries whose proposed replacement commits don't";
|
|
6788
6671
|
console.log(`${invisibleIds.length ? "Also nothing" : "Nothing"} applied for ${subject} resolve in this repository: ${withheldIds.join(", ")}. Left queued — this won't self-heal on its own; \`hunch repair-provenance --drop <dec_id>\` to reject it.`);
|
|
6789
6672
|
}
|
|
6790
6673
|
}
|
|
@@ -6792,37 +6675,28 @@ program
|
|
|
6792
6675
|
}
|
|
6793
6676
|
store.reindex();
|
|
6794
6677
|
const rangeLabel = rangeResolves ? `${oldRef}..${newRef}` : "queued";
|
|
6795
|
-
pumpMemoryHomes(store, root,
|
|
6678
|
+
pumpMemoryHomes(store, root, homes, `hunch: repair ${appliedIds.size} commit reference(s) after squash-merge (${rangeLabel})`);
|
|
6796
6679
|
if (!opts.quiet) {
|
|
6797
6680
|
console.log(`✓ Repaired ${appliedIds.size} commit reference(s):`);
|
|
6798
|
-
|
|
6799
|
-
|
|
6800
|
-
|
|
6801
|
-
// would print here too (#51).
|
|
6802
|
-
for (const r of applicable)
|
|
6803
|
-
if (appliedIds.has(r.id) && wasChosen(r))
|
|
6681
|
+
for (const r of applicable) {
|
|
6682
|
+
// A losing same-id sibling must not be listed.
|
|
6683
|
+
if (appliedIds.has(r.id) && isChosen(r))
|
|
6804
6684
|
console.log(` ${r.id} ${r.from} → ${r.to}`);
|
|
6805
|
-
const stillQueued = toApply.filter((r) => staysQueued(r) || !resolvedIds.has(r.id));
|
|
6806
|
-
const stillInvisible = stillQueued.filter((r) => reasonQueued(r) === "invisible");
|
|
6807
|
-
const stillWithheld = stillQueued.filter((r) => reasonQueued(r) === "withheld");
|
|
6808
|
-
const stillDuplicate = stillQueued.filter((r) => reasonQueued(r) === "duplicate");
|
|
6809
|
-
if (stillInvisible.length) {
|
|
6810
|
-
console.log(`\n${stillInvisible.length} entr${stillInvisible.length === 1 ? "y" : "ies"} not visible this run, left queued: ${stillInvisible.map((r) => r.id).join(", ")} — or \`hunch repair-provenance --drop <dec_id>\` if a decision is gone for good.`);
|
|
6811
6685
|
}
|
|
6812
|
-
|
|
6813
|
-
|
|
6686
|
+
const left = toApply.filter(keep);
|
|
6687
|
+
const plural = (n) => (n === 1 ? "entry" : "entries");
|
|
6688
|
+
const invisibleLeft = left.filter((r) => stayReason(r) === "invisible");
|
|
6689
|
+
const withheldLeft = left.filter((r) => stayReason(r) === "withheld");
|
|
6690
|
+
const duplicateLeft = left.filter((r) => stayReason(r) === "duplicate");
|
|
6691
|
+
if (invisibleLeft.length) {
|
|
6692
|
+
console.log(`\n${invisibleLeft.length} ${plural(invisibleLeft.length)} not visible this run, left queued: ${invisibleLeft.map((r) => r.id).join(", ")} — or \`hunch repair-provenance --drop <dec_id>\` if a decision is gone for good.`);
|
|
6693
|
+
}
|
|
6694
|
+
if (withheldLeft.length) {
|
|
6695
|
+
console.log(`\n${withheldLeft.length} ${plural(withheldLeft.length)} left queued — the proposed replacement commit doesn't resolve in this repository: ${withheldLeft.map((r) => r.id).join(", ")} — \`hunch repair-provenance --drop <dec_id>\` to reject it.`);
|
|
6814
6696
|
}
|
|
6815
|
-
if (
|
|
6816
|
-
//
|
|
6817
|
-
|
|
6818
|
-
// value, so name the `to` each one proposed instead. No
|
|
6819
|
-
// `--drop <dec_id>` pointer, unlike the other two buckets: `--drop`
|
|
6820
|
-
// targets the first queued match for an id (the same one
|
|
6821
|
-
// pickRewrite would apply), which by now is the winning sibling
|
|
6822
|
-
// that's already gone from the queue, not the losing one named
|
|
6823
|
-
// here — pointing at `--drop <dec_id>` would read as an offer to
|
|
6824
|
-
// reject this exact entry when it can't (#53).
|
|
6825
|
-
console.log(`\n${stillDuplicate.length} entr${stillDuplicate.length === 1 ? "y" : "ies"} left queued — a sibling entry for the same decision was applied instead (only the first queued match per decision is ever applied): ${stillDuplicate.map((r) => `${r.id} (${r.to})`).join(", ")}`);
|
|
6697
|
+
if (duplicateLeft.length) {
|
|
6698
|
+
// No --drop pointer: drop targets the first match, which is the winner already gone.
|
|
6699
|
+
console.log(`\n${duplicateLeft.length} ${plural(duplicateLeft.length)} left queued — a sibling entry for the same decision was applied instead (only the first queued match per decision is ever applied): ${duplicateLeft.map((r) => `${r.id} (${r.to})`).join(", ")}`);
|
|
6826
6700
|
}
|
|
6827
6701
|
}
|
|
6828
6702
|
}
|
|
@@ -7362,13 +7236,13 @@ program
|
|
|
7362
7236
|
console.log(`· ${f.id} — ${f.detail}`);
|
|
7363
7237
|
console.log(`\nHeal: this is a HUMAN call — the decision's authority is unchanged until you make it. Re-attest (update the premise's review_by/attested), supersede via /capture, or retire the decision. Keeping it for consistency is a valid answer.\n`);
|
|
7364
7238
|
}
|
|
7365
|
-
// Every drift kind
|
|
7239
|
+
// Every drift kind must heal here.
|
|
7366
7240
|
const commitUnresolvable = kind("commit-unresolvable");
|
|
7367
7241
|
if (commitUnresolvable.length) {
|
|
7368
7242
|
console.log(`${commitUnresolvable.length} decision(s) cite a commit that no longer resolves in this repository:\n`);
|
|
7369
7243
|
for (const f of commitUnresolvable)
|
|
7370
7244
|
console.log(`· ${f.id} — ${f.detail}`);
|
|
7371
|
-
console.log(`\nHeal: this is a HUMAN call
|
|
7245
|
+
console.log(`\nHeal: this is a HUMAN call. Check \`hunch repair-provenance\` for a queued match (the post-merge hook detects them while the commit is still resolvable, but never applies one unattended), then run \`hunch repair-provenance --apply\` to confirm it; if the commit is already gone, correct the provenance by hand or keep it as historical record.\n`);
|
|
7372
7246
|
}
|
|
7373
7247
|
const fenceUnclosed = kind("doc-fence-unclosed");
|
|
7374
7248
|
if (fenceUnclosed.length) {
|
|
@@ -7479,20 +7353,21 @@ program
|
|
|
7479
7353
|
process.exitCode = 1; // conflict markers remain → block the commit for review
|
|
7480
7354
|
}
|
|
7481
7355
|
});
|
|
7482
|
-
// ---- merge-driver-grounding (internal
|
|
7356
|
+
// ---- merge-driver-grounding (internal) -----------------------------------
|
|
7483
7357
|
program
|
|
7484
7358
|
.command("merge-driver-grounding")
|
|
7485
|
-
.description("(internal) git merge driver for the generated grounding docs
|
|
7486
|
-
.argument("<base>", "
|
|
7487
|
-
.argument("<ours>", "
|
|
7488
|
-
.argument("<theirs>", "
|
|
7489
|
-
.argument("[path]", "%P
|
|
7359
|
+
.description("(internal) git's merge driver for the generated grounding docs: auto-resolves a hard conflict confined to the record-counts sentence.")
|
|
7360
|
+
.argument("<base>", "common ancestor (%O)")
|
|
7361
|
+
.argument("<ours>", "current branch version, and the output file (%A)")
|
|
7362
|
+
.argument("<theirs>", "other branch version (%B)")
|
|
7363
|
+
.argument("[path]", "pathname (%P)")
|
|
7490
7364
|
.action((base, ours, theirs) => {
|
|
7491
|
-
const
|
|
7492
|
-
if (
|
|
7493
|
-
writeFileSync(ours,
|
|
7494
|
-
|
|
7495
|
-
|
|
7365
|
+
const result = mergeGroundingFile(base, ours, theirs);
|
|
7366
|
+
if (result.write !== null)
|
|
7367
|
+
writeFileSync(ours, result.write);
|
|
7368
|
+
// A real conflict or a git error: leave it for a human.
|
|
7369
|
+
if (result.conflict)
|
|
7370
|
+
process.exitCode = 1;
|
|
7496
7371
|
});
|
|
7497
7372
|
// ---- doctor ---------------------------------------------------------------
|
|
7498
7373
|
program
|
|
@@ -7535,11 +7410,9 @@ program
|
|
|
7535
7410
|
const { store, root } = storeFor();
|
|
7536
7411
|
console.log(`Hunch root: ${root}`);
|
|
7537
7412
|
console.log(`git repo: ${isGitRepo(root) ? "yes" : "no"} ${isGitRepo(root) ? `(HEAD ${headSha(root).slice(0, 8)})` : ""}`);
|
|
7538
|
-
// post-commit
|
|
7539
|
-
//
|
|
7540
|
-
//
|
|
7541
|
-
// squash-merge went unrepaired. pre-commit is opt-out (`--no-enforce`),
|
|
7542
|
-
// so its absence is informational only, never a warning.
|
|
7413
|
+
// post-commit and post-merge are the two hooks every setup path installs; a repo
|
|
7414
|
+
// initialized before post-merge existed never got it and had no way to notice.
|
|
7415
|
+
// pre-commit is opt-out (--no-enforce), so its absence is informational only.
|
|
7543
7416
|
if (isGitRepo(root)) {
|
|
7544
7417
|
const report = hookReport(root);
|
|
7545
7418
|
const hooks = hookStatus(root);
|
|
@@ -7621,7 +7494,7 @@ program
|
|
|
7621
7494
|
for (const line of synthesisStatusLines(resolution, process.env))
|
|
7622
7495
|
console.log(line);
|
|
7623
7496
|
const ctxWarning = await maybeWarnOllamaContext(provider.name, process.env);
|
|
7624
|
-
if (ctxWarning)
|
|
7497
|
+
if (ctxWarning !== null)
|
|
7625
7498
|
console.log(ctxWarning);
|
|
7626
7499
|
// The native addons load on first PARSE, not at import, so no other command
|
|
7627
7500
|
// that merely starts up proves the parser works. Doctor is the one place
|
|
@@ -7757,27 +7630,75 @@ function readStdin() {
|
|
|
7757
7630
|
});
|
|
7758
7631
|
}
|
|
7759
7632
|
/** Absolute edit path → repo-relative, forward-slash (constraint scopes are
|
|
7760
|
-
* forward-slash globs even on Windows)
|
|
7761
|
-
* inside `root`.
|
|
7762
|
-
* which realpath-normalizes both ends
|
|
7763
|
-
*
|
|
7764
|
-
*
|
|
7765
|
-
* callers want the original target back to fail their own match safely. This
|
|
7766
|
-
* adapter converts that "still absolute" signal to "" — the sentinel `onEdit`
|
|
7767
|
-
* and `editTargets` below already treat as "skip, not in this repo". */
|
|
7633
|
+
* forward-slash globs even on Windows). Returns "" when the path isn't resolvably
|
|
7634
|
+
* inside `root`. A thin adapter over the shared `repoRelativeTarget` in core/paths.ts,
|
|
7635
|
+
* which realpath-normalizes both ends but passes an unresolvable absolute path through
|
|
7636
|
+
* unchanged; this converts that "still absolute" result into "", the sentinel that
|
|
7637
|
+
* `onEdit` and `editTargets` treat as "skip, not in this repo". */
|
|
7768
7638
|
function toRepoRel(root, abs) {
|
|
7769
7639
|
const rel = repoRelativeTarget(abs, root);
|
|
7770
|
-
|
|
7640
|
+
if (isAbsolute(rel) || /^[A-Za-z]:/.test(rel))
|
|
7641
|
+
return "";
|
|
7642
|
+
return rel;
|
|
7643
|
+
}
|
|
7644
|
+
/** The repository root a hook event belongs to. Normally the process root, but an
|
|
7645
|
+
* edit to a file in another LINKED worktree of the same repository (or a subagent
|
|
7646
|
+
* started in one) belongs to that worktree's memory: grounded against the process
|
|
7647
|
+
* root it was dropped as "outside the repo", with no output at all. Re-roots only
|
|
7648
|
+
* to a worktree sharing this repository's git common dir that carries its own
|
|
7649
|
+
* .hunch/; one without it is reported as ungrounded rather than skipped silently.
|
|
7650
|
+
* Checkouts are told apart by their `.git` entry, never by a .hunch/ hit, so a
|
|
7651
|
+
* nested package .hunch/ inside this checkout (a monorepo) never displaces the
|
|
7652
|
+
* process root and its blocking constraints. Git is spawned only for a file in a
|
|
7653
|
+
* different checkout than the process root's. */
|
|
7654
|
+
function hookEventRoot(processRoot, evt) {
|
|
7655
|
+
const toolEvent = evt.hook_event_name === "PreToolUse" || evt.hook_event_name === "PostToolUse" || evt.hook_event_name === "PostToolUseFailure";
|
|
7656
|
+
const subject = evt.hook_event_name === "SubagentStart" ? evt.cwd
|
|
7657
|
+
: toolEvent ? (evt.tool_input?.patch_files?.[0]?.path ?? evt.tool_input?.file_path) : undefined;
|
|
7658
|
+
if (!subject || !isAbsolute(subject))
|
|
7659
|
+
return { root: processRoot };
|
|
7660
|
+
try {
|
|
7661
|
+
const ownerTop = checkoutTop(evt.hook_event_name === "SubagentStart" ? subject : dirname(subject));
|
|
7662
|
+
const processTop = checkoutTop(processRoot);
|
|
7663
|
+
if (!ownerTop || !processTop || sameFilesystemEntry(ownerTop, processTop))
|
|
7664
|
+
return { root: processRoot };
|
|
7665
|
+
const common = checkoutCommonDir(processRoot);
|
|
7666
|
+
const ownerCommon = common ? checkoutCommonDir(ownerTop) : "";
|
|
7667
|
+
if (!common || !ownerCommon || !sameFilesystemEntry(common, ownerCommon))
|
|
7668
|
+
return { root: processRoot };
|
|
7669
|
+
// The same Hunch root inside the other worktree: its top, or the package dir a
|
|
7670
|
+
// subdirectory-rooted process uses.
|
|
7671
|
+
const owner = join(ownerTop, relative(processTop, processRoot));
|
|
7672
|
+
if (isDir(join(owner, ".hunch")))
|
|
7673
|
+
return { root: owner };
|
|
7674
|
+
return { root: processRoot, ungrounded: `Hunch: ${toPosixTarget(subject)} is in linked worktree ${toPosixTarget(ownerTop)}, which has no .hunch/ — this edit proceeds ungrounded.` };
|
|
7675
|
+
}
|
|
7676
|
+
catch {
|
|
7677
|
+
return { root: processRoot };
|
|
7678
|
+
}
|
|
7679
|
+
}
|
|
7680
|
+
/** The nearest directory at or above `start` holding a `.git` entry (a checkout's top;
|
|
7681
|
+
* a linked worktree's `.git` is a file), or "" outside every checkout. Pure fs. */
|
|
7682
|
+
function checkoutTop(start) {
|
|
7683
|
+
let cur = resolve(start);
|
|
7684
|
+
for (;;) {
|
|
7685
|
+
if (existsSync(join(cur, ".git")))
|
|
7686
|
+
return cur;
|
|
7687
|
+
const parent = dirname(cur);
|
|
7688
|
+
if (parent === cur)
|
|
7689
|
+
return "";
|
|
7690
|
+
cur = parent;
|
|
7691
|
+
}
|
|
7771
7692
|
}
|
|
7772
7693
|
/** Files a shell command wrote get the grounding the edit tools would have
|
|
7773
7694
|
* delivered before the edit — late, but while the agent can still revise.
|
|
7774
7695
|
* Already-served grounding (a delta) is not repeated. */
|
|
7775
7696
|
const MAX_SHELL_GROUNDED = 3;
|
|
7776
|
-
function shellWriteGrounding(root, store, provider, evt, written) {
|
|
7697
|
+
function shellWriteGrounding(root, store, provider, evt, written, ledgerRoot = root) {
|
|
7777
7698
|
const parts = [];
|
|
7778
7699
|
const grounded = [];
|
|
7779
7700
|
for (const target of written.slice(0, MAX_SHELL_GROUNDED)) {
|
|
7780
|
-
const g = fileGrounding(root, store, provider, evt, target, join(root, target));
|
|
7701
|
+
const g = fileGrounding(root, store, provider, evt, target, join(root, target), ledgerRoot);
|
|
7781
7702
|
if (g?.mode === "full") {
|
|
7782
7703
|
parts.push(g.text);
|
|
7783
7704
|
grounded.push(target);
|
|
@@ -7896,8 +7817,9 @@ const SIBLING_MEMORY_BUDGET_TOKENS = 800;
|
|
|
7896
7817
|
* retired code, doc anchors, recent tasks and sibling-fix lessons. `delta` when
|
|
7897
7818
|
* identical grounding was already served this session, null when Hunch knows
|
|
7898
7819
|
* nothing about the file. Serves the pre-edit hook and files a shell command
|
|
7899
|
-
* wrote (which never pass through the edit tools).
|
|
7900
|
-
|
|
7820
|
+
* wrote (which never pass through the edit tools). `ledgerRoot` is where the prompt's
|
|
7821
|
+
* task lives — the process root when the event was re-rooted to a linked worktree. */
|
|
7822
|
+
function fileGrounding(root, store, provider, evt, target, abs, ledgerRoot = root) {
|
|
7901
7823
|
// Decision-grounding for PROSE (doc≠graph): a markdown target that declares
|
|
7902
7824
|
// <!-- hunch:topic … --> anchors gets each topic's CURRENT decision — the
|
|
7903
7825
|
// graph outranks the prose being edited, and a stale pin is called out inline.
|
|
@@ -7914,7 +7836,7 @@ function fileGrounding(root, store, provider, evt, target, abs) {
|
|
|
7914
7836
|
// at warning, not relevance guesses. No selection (a host without a prompt
|
|
7915
7837
|
// hook, a legacy session, a prompt that selected nothing) keeps the unfiltered
|
|
7916
7838
|
// grounding.
|
|
7917
|
-
const selection = loadTaskSelection(hookReportTaskId(
|
|
7839
|
+
const selection = loadTaskSelection(hookReportTaskId(ledgerRoot, provider, evt));
|
|
7918
7840
|
const assembled = store.assembleContext(target);
|
|
7919
7841
|
const selected = selection ? new Set(selection.qualifying) : null;
|
|
7920
7842
|
const ctx = selected ? {
|
|
@@ -7949,7 +7871,7 @@ function fileGrounding(root, store, provider, evt, target, abs) {
|
|
|
7949
7871
|
const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
|
|
7950
7872
|
// Recent-task history is advisory; under a task selection it is dropped
|
|
7951
7873
|
// (`hunch task list` still has it).
|
|
7952
|
-
const recentTasks = selected ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(
|
|
7874
|
+
const recentTasks = selected ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(ledgerRoot, hookReportTaskId(ledgerRoot, provider, evt), target, { excludeTargetDeliveries: true })), target);
|
|
7953
7875
|
const hasContent = ctx.constraints.length ||
|
|
7954
7876
|
ctx.decisions.length ||
|
|
7955
7877
|
ctx.bugs.length ||
|
|
@@ -8013,7 +7935,7 @@ function fileGrounding(root, store, provider, evt, target, abs) {
|
|
|
8013
7935
|
delivery_profile: envelope.profile, ranking_policy: envelope.ranking_policy,
|
|
8014
7936
|
})),
|
|
8015
7937
|
]);
|
|
8016
|
-
const reportTaskId = hookReportTaskId(
|
|
7938
|
+
const reportTaskId = hookReportTaskId(ledgerRoot, provider, evt);
|
|
8017
7939
|
// A new authoritative prompt gets its own full delivery. An earlier
|
|
8018
7940
|
// prompt's session-level delta cannot establish this task's receipt.
|
|
8019
7941
|
// A subagent reports to the prompt's task but starts with FRESH context:
|
|
@@ -8040,8 +7962,8 @@ function fileGrounding(root, store, provider, evt, target, abs) {
|
|
|
8040
7962
|
const snapshots = snapshotDeliveredRecords(store, envelope);
|
|
8041
7963
|
// The first time a lesson reaches this prompt's task, tell the USER in one
|
|
8042
7964
|
// line (systemMessage); repeats of the same revision stay silent.
|
|
8043
|
-
recalled = reportPresentationEnabled(
|
|
8044
|
-
const occurrence = recordTaskDelivery(
|
|
7965
|
+
recalled = reportPresentationEnabled(ledgerRoot) ? renderRecalledLine(unseenLessons(ledgerRoot, reportTaskId, snapshots)) : null;
|
|
7966
|
+
const occurrence = recordTaskDelivery(ledgerRoot, reportTaskId, envelope, snapshots, undefined, target);
|
|
8045
7967
|
reportNotice = `\n\nHunch task ${reportTaskId} · delivery ${occurrence}. Inspect exact application references with hunch_report(task_id).`;
|
|
8046
7968
|
}
|
|
8047
7969
|
catch {
|