@davesheffer/hunch 1.45.2 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +290 -353
- package/dist/cli/invocation.d.ts +22 -13
- package/dist/cli/invocation.js +49 -29
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/agenthook.js +26 -6
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/correction.js +9 -9
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- 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/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/projectDna.js +11 -1
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- 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 +8 -23
- package/dist/extractors/git.js +77 -95
- 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 +168 -184
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +37 -37
- package/dist/extractors/languages.js +83 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +27 -13
- package/dist/extractors/parse.js +120 -56
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +13 -16
- package/dist/integrations/hooks.js +31 -30
- package/dist/integrations/mergeDriver.js +18 -17
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +4 -2
- package/dist/integrations/uninstall.d.ts +10 -4
- package/dist/integrations/uninstall.js +22 -18
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +107 -81
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +3 -1
- package/dist/serve/writelock.js +17 -3
- package/dist/store/hunchStore.d.ts +2 -1
- package/dist/store/hunchStore.js +20 -23
- package/dist/store/jsonStore.d.ts +13 -3
- package/dist/store/jsonStore.js +37 -13
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +93 -154
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +33 -50
- package/package.json +3 -3
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -22,13 +22,16 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
import { Command } from "commander";
|
|
23
23
|
import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir, realpathNorm, repoRelativeTarget } from "../core/paths.js";
|
|
24
24
|
import { writeFileAtomic } from "../core/io.js";
|
|
25
|
+
import { boundedSpawnSync } from "../core/boundedSpawn.js";
|
|
25
26
|
import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
|
|
26
27
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
27
28
|
import { registerIntegrationCommands } from "./integrations.js";
|
|
28
29
|
import { registerTaskReportCommands } from "./taskReport.js";
|
|
29
30
|
import { registerStateCommands } from "./state.js";
|
|
30
31
|
import { DEFAULT_CONFIG as SERVE_DEFAULT_CONFIG_FILE, registerServeCommands } from "./serve.js";
|
|
32
|
+
import { readServeConfig } from "../serve/config.js";
|
|
31
33
|
import { registerUpdateCommand } from "./update.js";
|
|
34
|
+
import { registerVerifyCommand } from "./verify.js";
|
|
32
35
|
import { registerReviewMemoryCommands } from "./reviewMemory.js";
|
|
33
36
|
import { detectInitiator, normalizeInitiator } from "../synthesis/initiator.js";
|
|
34
37
|
import { inspectIntegrations, formatIntegrationHealth, integrationHealthFails, integrationSessionWarning } from "../integrations/health.js";
|
|
@@ -62,7 +65,7 @@ import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, share
|
|
|
62
65
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
63
66
|
import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade, memoryIgnoreRule, describeMemoryIgnoreRule } from "../integrations/gitignore.js";
|
|
64
67
|
import { writeCiWorkflow } from "../integrations/ciAction.js";
|
|
65
|
-
import { applyUninstall, formatUninstallPlan, isOptedOut, OPT_OUT_FILE,
|
|
68
|
+
import { applyUninstall, formatUninstallPlan, isOptedOut, OPT_OUT_FILE, optOutMarkerAt, optOutMessage, planUninstall } from "../integrations/uninstall.js";
|
|
66
69
|
import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
|
|
67
70
|
import { HOOK_REMINDER } from "../core/hookText.js";
|
|
68
71
|
import { measureFootprint } from "../core/footprint.js";
|
|
@@ -188,6 +191,7 @@ registerTaskReportCommands(program, () => { const { store, root } = storeFor();
|
|
|
188
191
|
registerServeCommands(program);
|
|
189
192
|
registerStateCommands(program);
|
|
190
193
|
registerUpdateCommand(program);
|
|
194
|
+
registerVerifyCommand(program);
|
|
191
195
|
registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
|
|
192
196
|
const { store, root } = storeFor();
|
|
193
197
|
if (!repositoryUsesRemote(root, `https://github.com/${repository}.git`)) {
|
|
@@ -228,6 +232,103 @@ registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
|
|
|
228
232
|
// run: a leftover driver that exits without writing %A would let git drop `theirs`.
|
|
229
233
|
const OPT_OUT_ALLOWED = new Set(["uninstall", "doctor", "help", "merge-driver", "merge-driver-grounding"]);
|
|
230
234
|
const OPT_OUT_QUIET = new Set(["hook", "sync", "check", "grounding", "repair-provenance", "workspaces"]);
|
|
235
|
+
/** Locations a command writes to (#488), keyed by command path: an opted-out repository
|
|
236
|
+
* must never receive them, so their repository is gated IN ADDITION to the cwd's.
|
|
237
|
+
* `base` is what the command itself resolves a relative value against: the cwd's
|
|
238
|
+
* repository root (`findRoot`), Git's top level (`verify`), or the cwd; `join` marks a
|
|
239
|
+
* command that joins the value onto that base even when absolute (`export-adr`).
|
|
240
|
+
* `checkout` marks a command that also writes into the MAIN checkout (the overlay
|
|
241
|
+
* anchor) and the git common dir (the shared-overlay pointer, the shared git hooks),
|
|
242
|
+
* so both are gated; with no target (`init`) only those are. */
|
|
243
|
+
const OPT_OUT_OUTPUTS = {
|
|
244
|
+
"init": { base: "root", checkout: true },
|
|
245
|
+
"private": { arg: 0, base: "root", checkout: true },
|
|
246
|
+
"shared": { arg: 0, base: "root", checkout: true },
|
|
247
|
+
"worktree": { arg: 0, base: "root", checkout: true },
|
|
248
|
+
"export-adr": { arg: 0, base: "root", join: true },
|
|
249
|
+
"review-memory auto": { option: "output", base: "cwd" },
|
|
250
|
+
"verify": { option: "map", base: "toplevel" },
|
|
251
|
+
};
|
|
252
|
+
/** Where `hunch private`/`shared` anchor the default overlay store: the MAIN worktree
|
|
253
|
+
* root, shared by the write and the opt-out gate so they cannot diverge. */
|
|
254
|
+
function overlayAnchor(root) {
|
|
255
|
+
return mainWorktreeRoot(root);
|
|
256
|
+
}
|
|
257
|
+
/** Git's top level for the cwd, as `hunch verify` resolves it; findRoot() outside Git. */
|
|
258
|
+
function gitToplevel() {
|
|
259
|
+
try {
|
|
260
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }).trim() || findRoot();
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return findRoot();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** The marker gating `path` (the cwd, or a location a command writes to). Its own store
|
|
267
|
+
* or repository root (`findRoot`: git-less stores included) counts first; inside a
|
|
268
|
+
* repository the walk then passes any nested `.hunch` store up to the repository
|
|
269
|
+
* (#488). A stray marker in a non-store, non-repository ancestor (e.g. ~) gates nothing. */
|
|
270
|
+
function markerFor(path) {
|
|
271
|
+
const real = realpathNorm(path);
|
|
272
|
+
const root = findRoot(real);
|
|
273
|
+
if (isOptedOut(root))
|
|
274
|
+
return join(root, OPT_OUT_FILE);
|
|
275
|
+
return optOutMarkerAt(real, { withinRepo: true });
|
|
276
|
+
}
|
|
277
|
+
/** Every location whose repository the opt-out gate judges for this invocation (#458,
|
|
278
|
+
* #488). A `--root` or a `serve` config names the repository the command acts on, so
|
|
279
|
+
* it REPLACES the cwd; `serve` also gates every partition root its config names (an
|
|
280
|
+
* unreadable config gates its directory only; the command itself then fails). An
|
|
281
|
+
* output location only ADDS to the cwd. */
|
|
282
|
+
function optOutTargets(top, action) {
|
|
283
|
+
const path = [];
|
|
284
|
+
for (let c = action; c && c !== program; c = c.parent)
|
|
285
|
+
path.unshift(c.name());
|
|
286
|
+
const given = (v) => typeof v === "string" && v.trim() !== "";
|
|
287
|
+
const actionOpts = action.opts();
|
|
288
|
+
const targets = [];
|
|
289
|
+
let cwd = true;
|
|
290
|
+
if (given(actionOpts.root)) {
|
|
291
|
+
targets.push(resolve(actionOpts.root));
|
|
292
|
+
cwd = false;
|
|
293
|
+
}
|
|
294
|
+
if (top.name() === "serve" && (path[1] === "init" || cwd)) {
|
|
295
|
+
// `serve` and `serve init` both take --config; the command reads the parent's first.
|
|
296
|
+
const configOpt = top.opts().config ?? actionOpts.config;
|
|
297
|
+
const configFile = resolve(given(configOpt) ? configOpt : SERVE_DEFAULT_CONFIG_FILE);
|
|
298
|
+
targets.push(dirname(configFile));
|
|
299
|
+
if (cwd) {
|
|
300
|
+
try {
|
|
301
|
+
for (const p of readServeConfig(configFile).partitions)
|
|
302
|
+
targets.push(p.root);
|
|
303
|
+
}
|
|
304
|
+
catch { /* the command reports it */ }
|
|
305
|
+
}
|
|
306
|
+
cwd = false;
|
|
307
|
+
}
|
|
308
|
+
// Output locations follow the cwd rule: a marker gates them only inside a repository,
|
|
309
|
+
// never from a non-repository ancestor (bare / --separate-git-dir common dirs included).
|
|
310
|
+
const outputs = [];
|
|
311
|
+
const out = OPT_OUT_OUTPUTS[path.join(" ")];
|
|
312
|
+
if (out) {
|
|
313
|
+
const value = out.option !== undefined ? actionOpts[out.option] : action.processedArgs[out.arg ?? 0];
|
|
314
|
+
if (given(value)) {
|
|
315
|
+
const base = out.base === "root" ? findRoot() : out.base === "toplevel" ? gitToplevel() : process.cwd();
|
|
316
|
+
// `join`: the path the command writes, plus the absolute path the user named.
|
|
317
|
+
if (out.join)
|
|
318
|
+
outputs.push(join(base, toPosixTarget(value)));
|
|
319
|
+
outputs.push(resolve(base, value));
|
|
320
|
+
}
|
|
321
|
+
if (out.checkout) {
|
|
322
|
+
const root = findRoot();
|
|
323
|
+
const common = gitCommonDir(root);
|
|
324
|
+
// Only a real linked worktree shares a common dir with a main checkout; a submodule's
|
|
325
|
+
// common dir (super/.git/modules/x) must not pull the superproject's marker in.
|
|
326
|
+
if (common && mainWorktreeRoot(root) !== root)
|
|
327
|
+
outputs.push(overlayAnchor(root), resolve(root, common));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { targets, outputs, cwd };
|
|
331
|
+
}
|
|
231
332
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
232
333
|
let top = actionCommand;
|
|
233
334
|
while (top.parent && top.parent !== program)
|
|
@@ -235,23 +336,26 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
|
235
336
|
const name = top.name();
|
|
236
337
|
if (OPT_OUT_ALLOWED.has(name))
|
|
237
338
|
return;
|
|
238
|
-
// Gate the repository
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
339
|
+
// Gate the repository each location acts on (#458, #488): its real path is walked up
|
|
340
|
+
// through its ancestors to its repository, past any nested `.hunch` store (fail
|
|
341
|
+
// closed, so a cwd inside a nested store is gated too), and the refusal names the
|
|
342
|
+
// marker that actually gates it.
|
|
343
|
+
const { targets, outputs, cwd } = optOutTargets(top, actionCommand);
|
|
344
|
+
let marker = cwd ? markerFor(process.cwd()) : null;
|
|
345
|
+
for (const target of targets) {
|
|
346
|
+
if (marker)
|
|
347
|
+
break;
|
|
348
|
+
marker = optOutMarkerAt(realpathNorm(target));
|
|
349
|
+
}
|
|
350
|
+
for (const target of outputs) {
|
|
351
|
+
if (marker)
|
|
352
|
+
break;
|
|
353
|
+
marker = markerFor(target);
|
|
354
|
+
}
|
|
355
|
+
if (!marker)
|
|
252
356
|
return;
|
|
253
357
|
if (name !== "hook")
|
|
254
|
-
console.error(optOutMessage());
|
|
358
|
+
console.error(optOutMessage(marker));
|
|
255
359
|
process.exit(OPT_OUT_QUIET.has(name) ? 0 : 1);
|
|
256
360
|
});
|
|
257
361
|
// Read an already-known update and schedule any registry refresh in a detached
|
|
@@ -553,8 +657,8 @@ program
|
|
|
553
657
|
}
|
|
554
658
|
// Windows self-heal: if an earlier global `claude mcp add` left a drive-letter
|
|
555
659
|
// case-split in ~/.claude.json, merge it so hunch resolves under either casing.
|
|
556
|
-
// No-op (silent) off Windows.
|
|
557
|
-
reportClaudeConfigHeal();
|
|
660
|
+
// No-op (silent) off Windows; the no-op line belongs to --details (#493).
|
|
661
|
+
reportClaudeConfigHeal(say);
|
|
558
662
|
// Worktree-seamless: register any configured overlay at the git common dir so EVERY
|
|
559
663
|
// worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
|
|
560
664
|
// and note when we're initializing inside a linked worktree (memory is shared, not separate).
|
|
@@ -642,12 +746,11 @@ program
|
|
|
642
746
|
// never rewrites an existing one: `index` runs in CI/release gates on a clean
|
|
643
747
|
// checkout. Older blocks are upgraded by `hunch update` / init / private setup.
|
|
644
748
|
ensureGitignore(root, { upgradeExisting: false });
|
|
645
|
-
// The post-merge hook
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
// is not a setup command (it runs in CI, on any git repo), so it must
|
|
749
|
+
// The post-merge hook used to be installed only by init / private / shared, so a
|
|
750
|
+
// repo initialized before it existed never got it. `index` already self-heals the
|
|
751
|
+
// gitignore and does the same for the hooks, so upgrading needs no re-init. The
|
|
752
|
+
// heal is gated on post-commit already being present: `index` is not a setup
|
|
753
|
+
// command (it runs in CI on arbitrary repos) and so it must
|
|
651
754
|
// never be what FIRST hooks a repo that never ran init at all. A STALE
|
|
652
755
|
// post-commit block counts too (issue #315): it proves init ran, so the
|
|
653
756
|
// missing post-merge/post-checkout hooks are still this repo's to heal.
|
|
@@ -721,9 +824,9 @@ program
|
|
|
721
824
|
const commits = logSince(opts.since, root, Number(opts.max));
|
|
722
825
|
const conc = Math.max(1, Math.min(16, Number(opts.concurrency) || 4));
|
|
723
826
|
console.log(`Backfilling from ${commits.length} commit(s) since ${opts.since} (concurrency ${conc})…`);
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
//
|
|
827
|
+
// One-time, best-effort context-window advisory: printed once before any commit is
|
|
828
|
+
// drafted (never per commit), and skipped under --deep since an ensemble may fan out
|
|
829
|
+
// to several workers with differing configurations.
|
|
727
830
|
if (!opts.deep && commits.length > 0) {
|
|
728
831
|
// { root }: the advisory must resolve the SAME provider the synthesis
|
|
729
832
|
// inside syncCommit resolves (persisted local.json preference included) —
|
|
@@ -731,7 +834,7 @@ program
|
|
|
731
834
|
// num_ctx warning for an actual Ollama run (issue #46).
|
|
732
835
|
const ctxProvider = await selectProvider({ root });
|
|
733
836
|
const ctxWarning = await maybeWarnOllamaContext(ctxProvider.name, process.env);
|
|
734
|
-
if (ctxWarning)
|
|
837
|
+
if (ctxWarning !== null)
|
|
735
838
|
console.log(ctxWarning);
|
|
736
839
|
}
|
|
737
840
|
let written = 0, skipped = 0, llm = 0, heuristic = 0;
|
|
@@ -985,13 +1088,12 @@ function discoverSharedRemote(remoteUrl) {
|
|
|
985
1088
|
&& remoteUrl.startsWith(prefix))
|
|
986
1089
|
return null;
|
|
987
1090
|
}
|
|
988
|
-
|
|
1091
|
+
// Bounded so a timeout kills git's whole tree, not just the root (#498).
|
|
1092
|
+
const listed = boundedSpawnSync("git", [
|
|
989
1093
|
"-C", probe,
|
|
990
1094
|
"-c", `core.hooksPath=${hooksDir}`,
|
|
991
1095
|
"ls-remote", "--refs", "--heads", "--upload-pack=git-upload-pack", remoteUrl,
|
|
992
1096
|
], {
|
|
993
|
-
encoding: "utf8",
|
|
994
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
995
1097
|
env: boundedTeamGitEnv(),
|
|
996
1098
|
timeout: 15_000,
|
|
997
1099
|
});
|
|
@@ -1151,9 +1253,8 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
|
|
|
1151
1253
|
const backLink = commonDir ? separateGitDirLink(commonDir) : "";
|
|
1152
1254
|
const configuredHooks = includeHook ? hooksDir(root) : "";
|
|
1153
1255
|
const hookDir = configuredHooks ? (isAbsolute(configuredHooks) ? configuredHooks : join(root, configuredHooks)) : "";
|
|
1154
|
-
// Setup installs
|
|
1155
|
-
//
|
|
1156
|
-
// overlay is not a rollback.
|
|
1256
|
+
// Setup installs both hooks together, so both must be snapshotted: restoring only one
|
|
1257
|
+
// while the other still points at a deleted overlay is not a rollback.
|
|
1157
1258
|
const hookFiles = hookDir ? [join(hookDir, "post-commit"), join(hookDir, "post-merge")] : [];
|
|
1158
1259
|
const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer, backLink] : []), ...hookFiles];
|
|
1159
1260
|
const snapshots = new Map(paths.map((path) => [path, setupPathSnapshot(path)]));
|
|
@@ -1175,7 +1276,8 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
|
|
|
1175
1276
|
markGitignoreWrite: () => mark(codeGitignore),
|
|
1176
1277
|
markTeamWrite: () => mark(teamFile),
|
|
1177
1278
|
markSharedPointerWrite: () => { mark(backLink); mark(sharedPointer); },
|
|
1178
|
-
markHookWrite: () => hookFiles
|
|
1279
|
+
markHookWrite: () => { for (const f of hookFiles)
|
|
1280
|
+
mark(f); },
|
|
1179
1281
|
// Migration is a one-way ownership handoff. Once public records have been
|
|
1180
1282
|
// durably copied into this clone, a later setup failure may restore routing
|
|
1181
1283
|
// files but must not delete the clone that now holds their surviving copy.
|
|
@@ -1324,7 +1426,7 @@ function configureOverlay(dir, opts, mode) {
|
|
|
1324
1426
|
// Anchor the default store at the MAIN worktree root: a linked worktree can be
|
|
1325
1427
|
// `git worktree remove`d, which would take the store (and every other worktree's
|
|
1326
1428
|
// absolute pointer to it) down with it. An explicit [dir] still resolves from here.
|
|
1327
|
-
const anchor =
|
|
1429
|
+
const anchor = overlayAnchor(root);
|
|
1328
1430
|
if (opts.repo) {
|
|
1329
1431
|
const dest = join(anchor, ".hunch-private");
|
|
1330
1432
|
// Canonicalize once in the invocation context so clone, attach, fetch, and
|
|
@@ -4807,20 +4909,21 @@ program
|
|
|
4807
4909
|
.requiredOption("--by <new>", "decision id that supersedes it")
|
|
4808
4910
|
.action((oldId, opts) => {
|
|
4809
4911
|
const { store, root } = storeFor();
|
|
4810
|
-
//
|
|
4811
|
-
// that SAME store, or a private `by` linked against a public `old` would write the
|
|
4812
|
-
// private id straight into the committed public store.
|
|
4912
|
+
// The old decision's own memory home decides which store both ids must live in.
|
|
4813
4913
|
const home = decisionMemoryHome(store, oldId);
|
|
4814
|
-
|
|
4914
|
+
const priv = home === "private";
|
|
4915
|
+
// Check the old id first so a missing old decision is never blamed on --by.
|
|
4916
|
+
if (!store.decisionInStore(oldId, priv)) {
|
|
4815
4917
|
store.close();
|
|
4816
4918
|
return fail(`decision "${oldId}" not found`);
|
|
4817
4919
|
}
|
|
4818
|
-
|
|
4920
|
+
// Resolve --by in the SAME store: a private --by must never be linked into a committed public record.
|
|
4921
|
+
const by = store.decisionInStore(opts.by, priv);
|
|
4819
4922
|
if (!by) {
|
|
4820
4923
|
store.close();
|
|
4821
4924
|
return fail(`--by decision "${opts.by}" not found in the ${home} store that holds "${oldId}"`);
|
|
4822
4925
|
}
|
|
4823
|
-
const closed =
|
|
4926
|
+
const closed = priv ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
|
|
4824
4927
|
if (!closed) {
|
|
4825
4928
|
store.close();
|
|
4826
4929
|
return fail(`decision "${oldId}" cannot supersede itself`);
|
|
@@ -4903,7 +5006,8 @@ program
|
|
|
4903
5006
|
.action(async (value) => {
|
|
4904
5007
|
const root = findRoot();
|
|
4905
5008
|
if (value != null) {
|
|
4906
|
-
const
|
|
5009
|
+
const trimmed = value.trim();
|
|
5010
|
+
const preference = normalizeProviderName(trimmed) || trimmed;
|
|
4907
5011
|
if (!SYNTH_PREFERENCES.includes(preference)) {
|
|
4908
5012
|
return fail(`provider must be one of: ${SYNTH_PREFERENCES.join(", ")}`);
|
|
4909
5013
|
}
|
|
@@ -4916,7 +5020,7 @@ program
|
|
|
4916
5020
|
console.log(`✓ local synthesis preference set to ${preference} (gitignored; it never changes a teammate's billing choice).`);
|
|
4917
5021
|
}
|
|
4918
5022
|
const resolution = await resolveSynthesisProvider({ root });
|
|
4919
|
-
const envValue = normalizeProviderName(process.env.HUNCH_SYNTH_PROVIDER
|
|
5023
|
+
const envValue = normalizeProviderName((process.env.HUNCH_SYNTH_PROVIDER ?? "").trim()) || undefined;
|
|
4920
5024
|
const local = readSynthesisPreference(root);
|
|
4921
5025
|
const hasValidEnv = !!envValue && SYNTH_PREFERENCES.includes(envValue);
|
|
4922
5026
|
console.log(`selected: ${resolution.provider.name} (${resolution.source})`);
|
|
@@ -5433,8 +5537,8 @@ program
|
|
|
5433
5537
|
// so its orientation event keeps asking every actionable entry.
|
|
5434
5538
|
const actionableEsc = deliversPromptEvent(provider) ? [] : (await actionableEscalationsWithAnchors(paths.root, s)).actionable;
|
|
5435
5539
|
if (!decisions.length && !actionableEsc.length) {
|
|
5436
|
-
//
|
|
5437
|
-
//
|
|
5540
|
+
// A fresh graph with nothing to raise has nothing to orient on, but the
|
|
5541
|
+
// operating loop is still delivered. A queued commit-repair escalation
|
|
5438
5542
|
// (checked against the full store) is enough reason NOT to bail here
|
|
5439
5543
|
// even when the visible decisions list is empty.
|
|
5440
5544
|
if (pipelineEnabled())
|
|
@@ -6094,12 +6198,15 @@ program
|
|
|
6094
6198
|
return;
|
|
6095
6199
|
}
|
|
6096
6200
|
const res = target.persistMigration();
|
|
6097
|
-
writeManifest(paths,
|
|
6201
|
+
writeManifest(paths, res.version); // held at `from` while any record failed to migrate
|
|
6098
6202
|
store.reindex();
|
|
6099
|
-
pumpMemoryHome(store, root, store.unified ? "private" : "public", `hunch: migrate schema v${from} to v${
|
|
6100
|
-
console.log(`✓ Migrated v${from} → v${
|
|
6203
|
+
pumpMemoryHome(store, root, store.unified ? "private" : "public", `hunch: migrate schema v${from} to v${res.version}`);
|
|
6204
|
+
console.log(`✓ Migrated v${from} → v${res.version}: ${res.migrated} record(s) upgraded.`);
|
|
6101
6205
|
if (res.skipped) {
|
|
6102
|
-
console.warn(`⚠ ${res.skipped} record(s) could
|
|
6206
|
+
console.warn(`⚠ ${res.skipped} record(s) could not be migrated. They are preserved on disk in their old shape under .hunch/ for manual recovery; the schema stays at v${res.version}, so re-running \`hunch migrate\` after a fix completes the migration to v${SCHEMA_VERSION}.`);
|
|
6207
|
+
}
|
|
6208
|
+
if (res.invalid) {
|
|
6209
|
+
console.warn(`⚠ ${res.invalid} record(s) are invalid or unreadable and no pending migration applies to them. They were left untouched under .hunch/; fix them by hand.`);
|
|
6103
6210
|
}
|
|
6104
6211
|
store.close();
|
|
6105
6212
|
});
|
|
@@ -6131,7 +6238,7 @@ program
|
|
|
6131
6238
|
// ---- escalations (the inline "ask the human" surface) ---------------------
|
|
6132
6239
|
program
|
|
6133
6240
|
.command("escalations")
|
|
6134
|
-
.description("The decisions a human must make
|
|
6241
|
+
.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.")
|
|
6135
6242
|
.option("--json", "emit the escalation entries as JSON (the VS Code panel's data source)")
|
|
6136
6243
|
.action(async (opts) => {
|
|
6137
6244
|
const { store, root } = storeFor();
|
|
@@ -6141,8 +6248,8 @@ program
|
|
|
6141
6248
|
// Premise decay rides the same inline surface: a decision whose recorded
|
|
6142
6249
|
// reason died is a QUESTION for the human — authority never changes here.
|
|
6143
6250
|
items.push(...premiseEscalations(decisionsForEsc, { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
|
|
6144
|
-
const
|
|
6145
|
-
items.push(...commitRepairEscalations(
|
|
6251
|
+
const queuedRepairs = readActivePendingRepairs(root);
|
|
6252
|
+
items.push(...commitRepairEscalations(queuedRepairs, decisionsForEsc, decisionsForEsc, withheldRewrites(root, queuedRepairs)));
|
|
6146
6253
|
// Constitution moments ride the same inline surface (§59.5.3) — never a queue.
|
|
6147
6254
|
// Fail open: a broken policy store must not take the memory escalations down.
|
|
6148
6255
|
try {
|
|
@@ -6150,10 +6257,10 @@ program
|
|
|
6150
6257
|
items.push(...policyEscalations(new CS(store, root).list().map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
|
|
6151
6258
|
}
|
|
6152
6259
|
catch { /* constitution unavailable — memory escalations still surface */ }
|
|
6153
|
-
//
|
|
6154
|
-
// commit-repair follower whose own resolution says
|
|
6155
|
-
//
|
|
6156
|
-
//
|
|
6260
|
+
// The exit code and tallies gate only on the actionable subset: a duplicate-id
|
|
6261
|
+
// commit-repair follower whose own resolution says to act on a different entry
|
|
6262
|
+
// first still surfaces for transparency, but is not its own decision. --json
|
|
6263
|
+
// keeps the full list.
|
|
6157
6264
|
const actionable = actionableEscalations(items);
|
|
6158
6265
|
if (opts.json) {
|
|
6159
6266
|
console.log(JSON.stringify(items));
|
|
@@ -6165,18 +6272,12 @@ program
|
|
|
6165
6272
|
console.log("✓ Nothing needs your decision — memory is auto-trusted and self-consistent.");
|
|
6166
6273
|
return;
|
|
6167
6274
|
}
|
|
6168
|
-
//
|
|
6169
|
-
//
|
|
6170
|
-
//
|
|
6171
|
-
|
|
6172
|
-
// `firstFor`/dropTarget. Kept anyway (not assumed away) because that
|
|
6173
|
-
// guarantee lives in the PRODUCERS, not in `Escalation.actionable`'s own
|
|
6174
|
-
// contract, which a future producer could legitimately violate.
|
|
6175
|
-
console.log(escalationHeadline(items, "cli") + "\n");
|
|
6275
|
+
// The headline's "nothing actionable, N shown for context" fallback is currently
|
|
6276
|
+
// unreachable (every producer guarantees at least one actionable entry), but that
|
|
6277
|
+
// guarantee lives in the producers, not in Escalation.actionable's contract.
|
|
6278
|
+
console.log(`${escalationHeadline(items, "cli")}\n`);
|
|
6176
6279
|
for (const e of items) {
|
|
6177
|
-
// A non-actionable
|
|
6178
|
-
// transparency, but must not read like its own question — marked
|
|
6179
|
-
// distinctly so a skim doesn't mistake it for one of the tally above.
|
|
6280
|
+
// A non-actionable follower is shown for context only, so it gets a quieter marker.
|
|
6180
6281
|
console.log(` ${e.actionable === false ? "·" : "⚖"} ${e.question}`);
|
|
6181
6282
|
console.log(` ${dim(e.detail)}`);
|
|
6182
6283
|
console.log(` ${dim("→ " + e.resolution)}\n`);
|
|
@@ -6319,17 +6420,17 @@ program
|
|
|
6319
6420
|
store.close();
|
|
6320
6421
|
}
|
|
6321
6422
|
});
|
|
6322
|
-
// ---- repair-provenance (squash-merge commit
|
|
6423
|
+
// ---- repair-provenance (squash-merge commit-provenance self-repair) -------
|
|
6323
6424
|
program
|
|
6324
6425
|
.command("repair-provenance")
|
|
6325
6426
|
.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.")
|
|
6326
6427
|
.option("--apply", "apply the reviewed queued candidates (requires --expect; does not scan for new matches)")
|
|
6327
6428
|
.option("--expect <hash>", "exact queue review hash printed by the preview or escalation; required for --apply/--drop")
|
|
6328
|
-
.option("--only <dec_id>", "with --apply, rewrite only this decision id
|
|
6329
|
-
.option("--drop <dec_id>", "reject the queued match for this decision id
|
|
6429
|
+
.option("--only <dec_id>", "with --apply, rewrite only this decision id; everything else stays queued untouched")
|
|
6430
|
+
.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)")
|
|
6330
6431
|
.option("--from-hook", "invoked by the git post-merge hook")
|
|
6331
6432
|
.option("--quiet", "minimal output")
|
|
6332
|
-
.option("--range <old..new>", "commit range to scan for replacement commits (default
|
|
6433
|
+
.option("--range <old..new>", "commit range to scan for replacement commits (default ORIG_HEAD..HEAD)")
|
|
6333
6434
|
.action((opts) => {
|
|
6334
6435
|
const { store, root } = storeFor();
|
|
6335
6436
|
try {
|
|
@@ -6344,41 +6445,32 @@ program
|
|
|
6344
6445
|
const parts = opts.range.split("..");
|
|
6345
6446
|
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
6346
6447
|
if (!opts.fromHook)
|
|
6347
|
-
fail(
|
|
6448
|
+
fail(`--range must look like "old..new"`);
|
|
6348
6449
|
return;
|
|
6349
6450
|
}
|
|
6350
|
-
|
|
6451
|
+
oldRef = parts[0];
|
|
6452
|
+
newRef = parts[1];
|
|
6351
6453
|
}
|
|
6352
6454
|
const rangeResolves = revExists(oldRef, root) && revExists(newRef, root);
|
|
6353
6455
|
if (!rangeResolves && opts.range) {
|
|
6354
|
-
// An explicitly-given range that doesn't resolve is a usage mistake, not
|
|
6355
|
-
// "no merge happened yet" — surface it. (The installed hook never passes
|
|
6356
|
-
// --range, so --from-hook never reaches this branch in practice.)
|
|
6357
6456
|
if (!opts.fromHook)
|
|
6358
6457
|
fail(`range "${oldRef}..${newRef}" does not resolve in this repository`);
|
|
6359
6458
|
return;
|
|
6360
6459
|
}
|
|
6460
|
+
// An unresolvable DEFAULT range just means no merge happened: detection is skipped below.
|
|
6361
6461
|
const decisions = store.recs("decisions");
|
|
6362
|
-
// One save path
|
|
6363
|
-
// queue semantics has exactly one write call to touch, not several
|
|
6364
|
-
// independently-reasoned ones. This file has twice shipped the same
|
|
6365
|
-
// failure class from that fragmentation: one write site's "is this
|
|
6366
|
-
// entry resolved" logic gets fixed while a sibling write site quietly
|
|
6367
|
-
// keeps reasoning about the queue differently, deleting an entry the
|
|
6368
|
-
// fixed site would have left alone.
|
|
6462
|
+
// One save path per file, so queue semantics cannot drift across write sites.
|
|
6369
6463
|
let queue = readPendingRepairs(root);
|
|
6370
|
-
const
|
|
6371
|
-
queue =
|
|
6464
|
+
const saveQueue = (next) => {
|
|
6465
|
+
queue = next;
|
|
6372
6466
|
writePendingRepairs(root, queue);
|
|
6373
6467
|
};
|
|
6374
|
-
// Tombstones
|
|
6375
|
-
//
|
|
6376
|
-
//
|
|
6377
|
-
// rejected. A genuinely different `to` for the same {id, from} is a new
|
|
6378
|
-
// proposal and is unaffected.
|
|
6468
|
+
// Tombstones hold the exact {id, from, to} triples a human rejected, so a later
|
|
6469
|
+
// merge re-deriving the identical match does not re-queue it; a different `to`
|
|
6470
|
+
// is a new proposal.
|
|
6379
6471
|
let dropped = readDroppedRepairs(root);
|
|
6380
6472
|
const saveDropped = (next) => {
|
|
6381
|
-
dropped =
|
|
6473
|
+
dropped = next;
|
|
6382
6474
|
writeDroppedRepairs(root, dropped);
|
|
6383
6475
|
};
|
|
6384
6476
|
// Review commands act only on the queue that was presented. Validate
|
|
@@ -6392,63 +6484,27 @@ program
|
|
|
6392
6484
|
if (opts.fromHook)
|
|
6393
6485
|
return fail("the post-merge hook may only detect repairs, never approve them.");
|
|
6394
6486
|
}
|
|
6395
|
-
//
|
|
6396
|
-
//
|
|
6397
|
-
|
|
6398
|
-
|
|
6399
|
-
|
|
6400
|
-
|
|
6401
|
-
// every load makes "never contains a rejected triple" a property of the
|
|
6402
|
-
// queue file itself, not of one write site. Same fragmentation lesson
|
|
6403
|
-
// as the comment on `save` above. Every OTHER reader of the queue
|
|
6404
|
-
// (escalations, SessionStart, the MCP tools) calls
|
|
6405
|
-
// readActivePendingRepairs instead of reading the raw file, so a
|
|
6406
|
-
// tombstoned entry can never surface as a question even before this
|
|
6407
|
-
// sweep next runs.
|
|
6408
|
-
//
|
|
6409
|
-
// Deletes unconditionally, without the prune's visibility caveats below
|
|
6410
|
-
// — safe here because the swept entry's exact triple is preserved in
|
|
6411
|
-
// dropped-commit-repairs.json, so nothing about it is unrecoverable
|
|
6412
|
-
// (unlike the prune, which could otherwise destroy the queue's only
|
|
6413
|
-
// durable record of a match).
|
|
6414
|
-
const activeInQueue = withoutDropped(queue, dropped);
|
|
6415
|
-
const swept = queue.filter((r) => !activeInQueue.includes(r));
|
|
6416
|
-
// Remembered for --only below, same reasoning as onlyWasPruned: targeting
|
|
6417
|
-
// an id whose only queued entry was just swept as already-rejected is not
|
|
6418
|
-
// the same usage error as targeting an id that never existed.
|
|
6419
|
-
const onlyWasSwept = !!opts.only && swept.some((r) => r.id === opts.only);
|
|
6487
|
+
// Sweep: entries whose exact triple is tombstoned. Deleted unconditionally since
|
|
6488
|
+
// the triple survives in the tombstone file.
|
|
6489
|
+
const activeQueue = withoutDropped(queue, dropped);
|
|
6490
|
+
const activeSet = new Set(activeQueue);
|
|
6491
|
+
const swept = queue.filter((r) => !activeSet.has(r));
|
|
6492
|
+
const onlySwept = !!opts.only && swept.some((r) => r.id === opts.only);
|
|
6420
6493
|
if (swept.length) {
|
|
6421
|
-
|
|
6494
|
+
saveQueue(activeQueue);
|
|
6422
6495
|
if (!opts.quiet)
|
|
6423
6496
|
console.log(`Swept ${swept.length} already-rejected match${swept.length === 1 ? "" : "es"} from the queue: ${swept.map((r) => r.id).join(", ")}`);
|
|
6424
6497
|
}
|
|
6425
|
-
// Prune
|
|
6426
|
-
//
|
|
6427
|
-
//
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
// actually-dead entry that outlives any number of --only runs
|
|
6434
|
-
// targeting other ids, without risking a false prune. Deferred until
|
|
6435
|
-
// after --range validation so a pure usage error changes nothing.
|
|
6436
|
-
const deadEntries = deadRewrites(queue, decisions);
|
|
6437
|
-
// Remembered for --only below: targeting an id that was pruned THIS
|
|
6438
|
-
// run is not the same usage error as targeting an id that never
|
|
6439
|
-
// existed — the human's target was real and is now resolved, not
|
|
6440
|
-
// unrecognized.
|
|
6441
|
-
const onlyWasPruned = !!opts.only && deadEntries.some((r) => r.id === opts.only);
|
|
6442
|
-
if (deadEntries.length) {
|
|
6443
|
-
// Object identity, not id — a corrupted queue file can carry a dead
|
|
6444
|
-
// entry and a still-live one sharing an id (#53); deadRewrites
|
|
6445
|
-
// already preserves identity via a plain filter, so pruning by id
|
|
6446
|
-
// here would destroy the live sibling right alongside its dead
|
|
6447
|
-
// namesake instead of leaving it for repairDecisionCommit to apply.
|
|
6448
|
-
const deadSet = new Set(deadEntries);
|
|
6449
|
-
save(queue.filter((r) => !deadSet.has(r)));
|
|
6498
|
+
// Prune: proven-dead entries only (never ones whose decision is merely absent this run).
|
|
6499
|
+
// Removed by object identity, not id: a corrupted queue may hold a dead and a live
|
|
6500
|
+
// entry sharing an id, and the live sibling must survive.
|
|
6501
|
+
const dead = deadRewrites(queue, decisions);
|
|
6502
|
+
const onlyPruned = !!opts.only && dead.some((r) => r.id === opts.only);
|
|
6503
|
+
if (dead.length) {
|
|
6504
|
+
const deadSet = new Set(dead);
|
|
6505
|
+
saveQueue(queue.filter((r) => !deadSet.has(r)));
|
|
6450
6506
|
if (!opts.quiet)
|
|
6451
|
-
console.log(`Pruned ${
|
|
6507
|
+
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(", ")}`);
|
|
6452
6508
|
}
|
|
6453
6509
|
const candidates = rangeResolves && !opts.apply && !opts.drop ? mergeRangeChanges(oldRef, newRef, root) : [];
|
|
6454
6510
|
// Orphaned means HEAD no longer reaches the commit either: a historical
|
|
@@ -6456,82 +6512,42 @@ program
|
|
|
6456
6512
|
const orphaned = candidates.length ? orphanedCommitDecisions(decisions, (sha) => commitRepairStatusAgainst(sha, [newRef, "HEAD"], root)) : [];
|
|
6457
6513
|
const patchIds = orphaned.length ? commitPatchIds([...orphaned.map((d) => d.commit), ...candidates.map((c) => c.sha)], root) : new Map();
|
|
6458
6514
|
const freshPlan = candidates.length ? planCommitRepair(orphaned, candidates, (sha) => patchIds.get(sha) ?? null) : { rewrites: [], records: [] };
|
|
6459
|
-
//
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
// next merge, so a human can confirm it later with `--apply` alone.
|
|
6464
|
-
// A match whose exact {id, from, to} triple was already tombstoned by
|
|
6465
|
-
// an earlier --drop is filtered out first — the human already rejected
|
|
6466
|
-
// exactly this proposed replacement, and a later merge re-deriving it
|
|
6467
|
-
// independently isn't a reason to ask again. A different `to` for the
|
|
6468
|
-
// same still-orphaned commit is a new proposal and passes through.
|
|
6469
|
-
const freshRewrites = withoutDropped(freshPlan.rewrites, dropped);
|
|
6470
|
-
if (freshRewrites.length)
|
|
6471
|
-
save(mergeRewrites(freshRewrites, queue));
|
|
6515
|
+
// Always persisted (local-only file): a hook-time detection survives for a later human --apply.
|
|
6516
|
+
const freshMatches = withoutDropped(freshPlan.rewrites, dropped);
|
|
6517
|
+
if (freshMatches.length)
|
|
6518
|
+
saveQueue(mergeRewrites(freshMatches, queue));
|
|
6472
6519
|
if (opts.drop) {
|
|
6473
6520
|
const before = queue.length;
|
|
6474
|
-
// firstFor is the single first-match-by-id rule — the same one
|
|
6475
|
-
// pickRewrite and mergeRewrites go through (#53, #56, #58) — to
|
|
6476
|
-
// decide which same-id entry would actually apply. A corrupted
|
|
6477
|
-
// queue file can carry two entries sharing an id, and dropping by
|
|
6478
|
-
// id would destroy the untargeted sibling too, with no tombstone
|
|
6479
|
-
// recording what it was. Filtering by object identity (not
|
|
6480
|
-
// `r.id !== opts.drop`) removes only the one entry actually
|
|
6481
|
-
// dropped; any other entry sharing the id is untouched and stays
|
|
6482
|
-
// queued.
|
|
6483
6521
|
const target = firstFor(queue, opts.drop);
|
|
6484
|
-
|
|
6485
|
-
// Tombstone only when something real was actually queued for this id —
|
|
6486
|
-
// an id that was never queued has no {from, to} to record, and
|
|
6487
|
-
// "nothing queued" is a usage-mistake signal that shouldn't quietly
|
|
6488
|
-
// create a tombstone file.
|
|
6522
|
+
saveQueue(queue.filter((r) => r !== target));
|
|
6489
6523
|
if (target) {
|
|
6490
6524
|
saveDropped(addDropped([{ id: target.id, from: target.from, to: target.to }], dropped));
|
|
6491
|
-
//
|
|
6492
|
-
|
|
6493
|
-
// run just saved above — the load-time sweep near the top of this
|
|
6494
|
-
// action already promises the queue file can never carry a
|
|
6495
|
-
// tombstoned entry, so re-run it here. Without this, --apply later
|
|
6496
|
-
// in this same invocation would happily write the exact rewrite
|
|
6497
|
-
// the human just rejected (#53 follow-up).
|
|
6498
|
-
save(withoutDropped(queue, dropped));
|
|
6525
|
+
// Re-sweep so an identical sibling cannot be applied later in this invocation.
|
|
6526
|
+
saveQueue(withoutDropped(queue, dropped));
|
|
6499
6527
|
}
|
|
6500
6528
|
if (!opts.quiet) {
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6529
|
+
if (queue.length === before && !target)
|
|
6530
|
+
console.log(`Nothing queued or matched for "${opts.drop}" to drop.`);
|
|
6531
|
+
else
|
|
6532
|
+
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.`);
|
|
6504
6533
|
}
|
|
6505
6534
|
if (!opts.apply)
|
|
6506
6535
|
return;
|
|
6507
6536
|
}
|
|
6508
|
-
// Deliberately
|
|
6509
|
-
// onlyWasPruned/onlyWasSwept below: when the prune or the sweep just
|
|
6510
|
-
// emptied the queue entirely, --only's own explanatory messages never
|
|
6511
|
-
// get a chance to fire — this generic message covers that case too,
|
|
6512
|
-
// and still exits 0 either way, so no usage-error bug survives here.
|
|
6537
|
+
// Deliberately before the --only messages.
|
|
6513
6538
|
if (!queue.length) {
|
|
6514
6539
|
if (!opts.quiet)
|
|
6515
6540
|
console.log("✓ Nothing to repair — no orphaned commit reference matched unambiguously, and nothing queued from an earlier run.");
|
|
6516
6541
|
return;
|
|
6517
6542
|
}
|
|
6518
|
-
// --only restricts the apply (or dry-run preview) to one decision id,
|
|
6519
|
-
// leaving every other queued/matched candidate exactly as it was — the
|
|
6520
|
-
// escalation asks a per-decision question, so the answer surface should
|
|
6521
|
-
// let a human accept one without also accepting everything else queued.
|
|
6522
6543
|
const toApply = opts.only ? queue.filter((r) => r.id === opts.only) : queue;
|
|
6523
6544
|
if (opts.only && !toApply.length) {
|
|
6524
|
-
if (
|
|
6525
|
-
// Not a usage error either: the human already rejected exactly this
|
|
6526
|
-
// match via an earlier --drop, and this run's sweep just confirmed
|
|
6527
|
-
// the rejection still holds — nothing left to apply.
|
|
6545
|
+
if (onlySwept) {
|
|
6528
6546
|
if (!opts.quiet)
|
|
6529
6547
|
console.log(`"${opts.only}" was already rejected via --drop — nothing left to do.`);
|
|
6530
6548
|
return;
|
|
6531
6549
|
}
|
|
6532
|
-
if (
|
|
6533
|
-
// Not a usage error: the human's target was real, and this run
|
|
6534
|
-
// already resolved it above (see the prune's own message for why).
|
|
6550
|
+
if (onlyPruned) {
|
|
6535
6551
|
if (!opts.quiet)
|
|
6536
6552
|
console.log(`"${opts.only}" was pruned earlier in this run — nothing left to do.`);
|
|
6537
6553
|
return;
|
|
@@ -6540,79 +6556,32 @@ program
|
|
|
6540
6556
|
fail(`no queued or matched entry for "${opts.only}"`);
|
|
6541
6557
|
return;
|
|
6542
6558
|
}
|
|
6543
|
-
//
|
|
6544
|
-
// that isn't can't actually be resolved by --apply (see
|
|
6545
|
-
// resolvedRewriteIds below), so dry-run/apply output must say so
|
|
6546
|
-
// instead of listing it as though --apply would act on it. Same
|
|
6547
|
-
// reasoning applies to a `to` that doesn't resolve to a real commit
|
|
6548
|
-
// here (issue #48): never write an unverified `to` into a decision's
|
|
6549
|
-
// commit field, and never claim --apply would either. Computed before
|
|
6550
|
-
// the dry-run branch below so the preview and the real run agree on
|
|
6551
|
-
// what --apply can actually do. `null` from commitsExist means the
|
|
6552
|
-
// check itself failed to run (not a git repo, git missing, timeout)
|
|
6553
|
-
// and is treated as fail-open — same discipline as drift.ts's own use
|
|
6554
|
-
// of commitsExist, though the asymmetry is worth naming: drift.ts only
|
|
6555
|
-
// decides what to REPORT on a `null`, while this gates a WRITE that
|
|
6556
|
-
// gets auto-committed into shared team memory. Fail-open is still the
|
|
6557
|
-
// right call here: `isGitRepo(root)` already passed above, so `null`
|
|
6558
|
-
// in practice means the batched `cat-file` call itself timed out —
|
|
6559
|
-
// rare, and a corrupted `to` that slips through is fully recoverable
|
|
6560
|
-
// (the entry survives durably in the queue either way; a bad write
|
|
6561
|
-
// is a revert). Fail-closed's cost — treating every entry as withheld
|
|
6562
|
-
// on a transient git error, indefinitely, on every run until the
|
|
6563
|
-
// error clears — is the worse failure mode to default to.
|
|
6559
|
+
// Apply-ability is computed before the dry-run branch so preview and apply agree.
|
|
6564
6560
|
const visibleIds = new Set(decisions.map((d) => d.id));
|
|
6565
|
-
|
|
6566
|
-
|
|
6561
|
+
// A null existence result is fail-open (treated as existing): isGitRepo already
|
|
6562
|
+
// passed, so null is a rare timeout, and a bad write is recoverable whereas
|
|
6563
|
+
// failing closed would withhold every entry indefinitely.
|
|
6564
|
+
const { applicable, withheld } = withheldForUnresolvableTo(toApply, commitsExist(toApply.map((r) => r.to), root));
|
|
6565
|
+
const applicableSet = new Set(applicable);
|
|
6567
6566
|
const withheldSet = new Set(withheld);
|
|
6568
|
-
// The plan --apply would act on if it ran right now — built here, before
|
|
6569
|
-
// the dry-run branch, so the preview reasons about the exact same plan
|
|
6570
|
-
// repairDecisionCommit would. `pickRewrite` (src/core/commitrepair.ts, a
|
|
6571
|
-
// plan-scoped `firstFor`) decides which entry wins when `applicable`
|
|
6572
|
-
// carries two sharing a decision id (a corrupted queue file, a hand
|
|
6573
|
-
// edit): first match, by construction. `wasChosen` asks that same
|
|
6574
|
-
// question by object identity rather than re-deriving it, so this file
|
|
6575
|
-
// and commitrepair.ts can never independently drift on which entry
|
|
6576
|
-
// "won" (#51, #58).
|
|
6577
6567
|
const plan = { rewrites: applicable, records: [...new Set(applicable.map((r) => r.id))] };
|
|
6578
|
-
const
|
|
6579
|
-
const
|
|
6580
|
-
// Whether an entry will still be sitting in the queue after this run,
|
|
6581
|
-
// and why — computed ONCE and shared by the dry-run preview, the queue
|
|
6582
|
-
// sweep, and the post-apply "left queued" report, so the three can
|
|
6583
|
-
// never independently drift out of sync about the same entry (they
|
|
6584
|
-
// did, twice, across two review rounds, before this was unified). The
|
|
6585
|
-
// sweep walks the FULL `queue`, not just `toApply` — under --only, an
|
|
6586
|
-
// untargeted id's entries never entered `applicable` at all, so
|
|
6587
|
-
// `wasChosen` would read "not chosen" for them too. Gating "duplicate"
|
|
6588
|
-
// on `applicableSet.has(r)` keeps the label meaning what it says: a
|
|
6589
|
-
// SIBLING actually competed for this exact id and lost, not merely
|
|
6590
|
-
// "this id wasn't this run's `applicable` set for some other reason."
|
|
6591
|
-
//
|
|
6592
|
-
// Precedence, most to least eclipsing: invisible, then withheld, then
|
|
6593
|
-
// duplicate. Invisible and withheld can each resolve on their own (a
|
|
6594
|
-
// later run sees the decision, or its `to` starts existing) or never
|
|
6595
|
-
// will (withheld, permanently, unless dropped) — "duplicate" is neither:
|
|
6596
|
-
// it means a SIBLING entry for the same id already won and got applied,
|
|
6597
|
-
// which repairDecisionCommit's own from-mismatch bail already makes true
|
|
6598
|
-
// by the time anything downstream would check it, so it's only worth
|
|
6599
|
-
// naming for entries that are otherwise resolvable.
|
|
6600
|
-
const reasonQueued = (r) => !visibleIds.has(r.id) ? "invisible"
|
|
6568
|
+
const isChosen = (r) => pickRewrite(plan, r.id) === r;
|
|
6569
|
+
const stayReason = (r) => !visibleIds.has(r.id) ? "invisible"
|
|
6601
6570
|
: withheldSet.has(r) ? "withheld"
|
|
6602
|
-
: applicableSet.has(r) && !
|
|
6571
|
+
: applicableSet.has(r) && !isChosen(r) ? "duplicate"
|
|
6603
6572
|
: null;
|
|
6604
|
-
const staysQueued = (r) =>
|
|
6573
|
+
const staysQueued = (r) => stayReason(r) !== null;
|
|
6605
6574
|
if (!opts.apply) {
|
|
6606
6575
|
if (!opts.quiet) {
|
|
6607
|
-
const
|
|
6608
|
-
console.log(
|
|
6609
|
-
? `Would repair ${toApply.length -
|
|
6576
|
+
const stayingCount = toApply.filter(staysQueued).length;
|
|
6577
|
+
console.log(stayingCount > 0
|
|
6578
|
+
? `Would repair ${toApply.length - stayingCount} of ${toApply.length} listed:`
|
|
6610
6579
|
: `Would repair ${toApply.length} commit reference(s):`);
|
|
6611
6580
|
// Match strength, so a reviewer can tell an identical change from a
|
|
6612
6581
|
// file-overlap guess before applying it into shared memory (#450).
|
|
6613
6582
|
const previewPatchIds = commitPatchIds(toApply.flatMap((r) => [r.from, r.to]), root);
|
|
6614
6583
|
for (const r of toApply) {
|
|
6615
|
-
const reason =
|
|
6584
|
+
const reason = stayReason(r);
|
|
6616
6585
|
const samePatch = !!previewPatchIds.get(r.from) && previewPatchIds.get(r.from) === previewPatchIds.get(r.to);
|
|
6617
6586
|
const label = reason === "invisible" ? "not visible this run — --apply would leave it queued"
|
|
6618
6587
|
: reason === "withheld" ? "proposed replacement commit doesn't resolve in this repository — --apply would leave it queued"
|
|
@@ -6630,7 +6599,7 @@ program
|
|
|
6630
6599
|
}
|
|
6631
6600
|
return;
|
|
6632
6601
|
}
|
|
6633
|
-
const
|
|
6602
|
+
const homes = new Set();
|
|
6634
6603
|
const appliedIds = new Set();
|
|
6635
6604
|
for (const d of decisions) {
|
|
6636
6605
|
const healed = repairDecisionCommit(d, plan);
|
|
@@ -6638,47 +6607,25 @@ program
|
|
|
6638
6607
|
continue;
|
|
6639
6608
|
const home = decisionMemoryHome(store, d.id);
|
|
6640
6609
|
store.putWhereItLives("decisions", healed);
|
|
6641
|
-
|
|
6610
|
+
homes.add(home);
|
|
6642
6611
|
appliedIds.add(d.id);
|
|
6643
6612
|
}
|
|
6644
|
-
//
|
|
6645
|
-
//
|
|
6646
|
-
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
// or whose replacement doesn't exist, is not proof of anything, so it
|
|
6650
|
-
// stays queued rather than being swept up just because it was in
|
|
6651
|
-
// `toApply`. This also covers --only: toApply is just the one targeted
|
|
6652
|
-
// id, so an invisible, unresolvable, or beaten-out target is left
|
|
6653
|
-
// queued instead of deleted unresolved.
|
|
6654
|
-
//
|
|
6655
|
-
// `staysQueued` (OBJECT identity for both the withheld half, via
|
|
6656
|
-
// `withheldSet.has(r)`, and the duplicate half, via `wasChosen`) is
|
|
6657
|
-
// OR'd in ahead of the id-keyed `!resolvedIds.has(r.id)`: a corrupted
|
|
6658
|
-
// queue file could carry two entries sharing an id — one resolvable,
|
|
6659
|
-
// one not, or both resolvable — and an id-keyed check alone would treat
|
|
6660
|
-
// the pair as one unit, deleting/misreporting whichever entry didn't
|
|
6661
|
-
// actually get applied (#48, #51).
|
|
6662
|
-
const resolvedIds = resolvedRewriteIds(applicable, decisions);
|
|
6663
|
-
save(queue.filter((r) => staysQueued(r) || !resolvedIds.has(r.id)));
|
|
6613
|
+
// Invisible / unresolvable / beaten-out entries are never deleted just because they
|
|
6614
|
+
// were targeted. Identity-keyed checks come first so same-id pairs are handled per entry.
|
|
6615
|
+
const resolved = resolvedRewriteIds(applicable, decisions);
|
|
6616
|
+
const keep = (r) => staysQueued(r) || !resolved.has(r.id);
|
|
6617
|
+
saveQueue(queue.filter(keep));
|
|
6664
6618
|
if (!appliedIds.size) {
|
|
6665
|
-
//
|
|
6666
|
-
//
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
// fires unconditionally for every visible match) — reaching here
|
|
6670
|
-
// means every targeted candidate stayed queued (invisible or
|
|
6671
|
-
// withheld, reported independently so neither reason disappears).
|
|
6672
|
-
const invisibleIds = toApply.filter((r) => reasonQueued(r) === "invisible").map((r) => r.id);
|
|
6673
|
-
const withheldIds = toApply.filter((r) => reasonQueued(r) === "withheld").map((r) => r.id);
|
|
6619
|
+
// Counted per entry (not per distinct id), the same way the post-apply
|
|
6620
|
+
// "left queued" summary below counts, so two same-id entries read as two.
|
|
6621
|
+
const invisibleIds = toApply.filter((r) => stayReason(r) === "invisible").map((r) => r.id);
|
|
6622
|
+
const withheldIds = toApply.filter((r) => stayReason(r) === "withheld").map((r) => r.id);
|
|
6674
6623
|
if (!opts.quiet) {
|
|
6675
6624
|
if (invisibleIds.length) {
|
|
6676
6625
|
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.`);
|
|
6677
6626
|
}
|
|
6678
6627
|
if (withheldIds.length) {
|
|
6679
|
-
const subject = withheldIds.length === 1
|
|
6680
|
-
? "an entry whose proposed replacement commit doesn't"
|
|
6681
|
-
: "entries whose proposed replacement commits don't";
|
|
6628
|
+
const subject = withheldIds.length === 1 ? "an entry whose proposed replacement commit doesn't" : "entries whose proposed replacement commits don't";
|
|
6682
6629
|
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.`);
|
|
6683
6630
|
}
|
|
6684
6631
|
}
|
|
@@ -6686,37 +6633,28 @@ program
|
|
|
6686
6633
|
}
|
|
6687
6634
|
store.reindex();
|
|
6688
6635
|
const rangeLabel = rangeResolves ? `${oldRef}..${newRef}` : "queued";
|
|
6689
|
-
pumpMemoryHomes(store, root,
|
|
6636
|
+
pumpMemoryHomes(store, root, homes, `hunch: repair ${appliedIds.size} commit reference(s) after squash-merge (${rangeLabel})`);
|
|
6690
6637
|
if (!opts.quiet) {
|
|
6691
6638
|
console.log(`✓ Repaired ${appliedIds.size} commit reference(s):`);
|
|
6692
|
-
|
|
6693
|
-
|
|
6694
|
-
|
|
6695
|
-
// would print here too (#51).
|
|
6696
|
-
for (const r of applicable)
|
|
6697
|
-
if (appliedIds.has(r.id) && wasChosen(r))
|
|
6639
|
+
for (const r of applicable) {
|
|
6640
|
+
// A losing same-id sibling must not be listed.
|
|
6641
|
+
if (appliedIds.has(r.id) && isChosen(r))
|
|
6698
6642
|
console.log(` ${r.id} ${r.from} → ${r.to}`);
|
|
6699
|
-
const stillQueued = toApply.filter((r) => staysQueued(r) || !resolvedIds.has(r.id));
|
|
6700
|
-
const stillInvisible = stillQueued.filter((r) => reasonQueued(r) === "invisible");
|
|
6701
|
-
const stillWithheld = stillQueued.filter((r) => reasonQueued(r) === "withheld");
|
|
6702
|
-
const stillDuplicate = stillQueued.filter((r) => reasonQueued(r) === "duplicate");
|
|
6703
|
-
if (stillInvisible.length) {
|
|
6704
|
-
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.`);
|
|
6705
6643
|
}
|
|
6706
|
-
|
|
6707
|
-
|
|
6644
|
+
const left = toApply.filter(keep);
|
|
6645
|
+
const plural = (n) => (n === 1 ? "entry" : "entries");
|
|
6646
|
+
const invisibleLeft = left.filter((r) => stayReason(r) === "invisible");
|
|
6647
|
+
const withheldLeft = left.filter((r) => stayReason(r) === "withheld");
|
|
6648
|
+
const duplicateLeft = left.filter((r) => stayReason(r) === "duplicate");
|
|
6649
|
+
if (invisibleLeft.length) {
|
|
6650
|
+
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.`);
|
|
6651
|
+
}
|
|
6652
|
+
if (withheldLeft.length) {
|
|
6653
|
+
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.`);
|
|
6708
6654
|
}
|
|
6709
|
-
if (
|
|
6710
|
-
//
|
|
6711
|
-
|
|
6712
|
-
// value, so name the `to` each one proposed instead. No
|
|
6713
|
-
// `--drop <dec_id>` pointer, unlike the other two buckets: `--drop`
|
|
6714
|
-
// targets the first queued match for an id (the same one
|
|
6715
|
-
// pickRewrite would apply), which by now is the winning sibling
|
|
6716
|
-
// that's already gone from the queue, not the losing one named
|
|
6717
|
-
// here — pointing at `--drop <dec_id>` would read as an offer to
|
|
6718
|
-
// reject this exact entry when it can't (#53).
|
|
6719
|
-
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(", ")}`);
|
|
6655
|
+
if (duplicateLeft.length) {
|
|
6656
|
+
// No --drop pointer: drop targets the first match, which is the winner already gone.
|
|
6657
|
+
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(", ")}`);
|
|
6720
6658
|
}
|
|
6721
6659
|
}
|
|
6722
6660
|
}
|
|
@@ -7256,13 +7194,13 @@ program
|
|
|
7256
7194
|
console.log(`· ${f.id} — ${f.detail}`);
|
|
7257
7195
|
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`);
|
|
7258
7196
|
}
|
|
7259
|
-
// Every drift kind
|
|
7197
|
+
// Every drift kind must heal here.
|
|
7260
7198
|
const commitUnresolvable = kind("commit-unresolvable");
|
|
7261
7199
|
if (commitUnresolvable.length) {
|
|
7262
7200
|
console.log(`${commitUnresolvable.length} decision(s) cite a commit that no longer resolves in this repository:\n`);
|
|
7263
7201
|
for (const f of commitUnresolvable)
|
|
7264
7202
|
console.log(`· ${f.id} — ${f.detail}`);
|
|
7265
|
-
console.log(`\nHeal: this is a HUMAN call
|
|
7203
|
+
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`);
|
|
7266
7204
|
}
|
|
7267
7205
|
const fenceUnclosed = kind("doc-fence-unclosed");
|
|
7268
7206
|
if (fenceUnclosed.length) {
|
|
@@ -7373,20 +7311,21 @@ program
|
|
|
7373
7311
|
process.exitCode = 1; // conflict markers remain → block the commit for review
|
|
7374
7312
|
}
|
|
7375
7313
|
});
|
|
7376
|
-
// ---- merge-driver-grounding (internal
|
|
7314
|
+
// ---- merge-driver-grounding (internal) -----------------------------------
|
|
7377
7315
|
program
|
|
7378
7316
|
.command("merge-driver-grounding")
|
|
7379
|
-
.description("(internal) git merge driver for the generated grounding docs
|
|
7380
|
-
.argument("<base>", "
|
|
7381
|
-
.argument("<ours>", "
|
|
7382
|
-
.argument("<theirs>", "
|
|
7383
|
-
.argument("[path]", "%P
|
|
7317
|
+
.description("(internal) git's merge driver for the generated grounding docs: auto-resolves a hard conflict confined to the record-counts sentence.")
|
|
7318
|
+
.argument("<base>", "common ancestor (%O)")
|
|
7319
|
+
.argument("<ours>", "current branch version, and the output file (%A)")
|
|
7320
|
+
.argument("<theirs>", "other branch version (%B)")
|
|
7321
|
+
.argument("[path]", "pathname (%P)")
|
|
7384
7322
|
.action((base, ours, theirs) => {
|
|
7385
|
-
const
|
|
7386
|
-
if (
|
|
7387
|
-
writeFileSync(ours,
|
|
7388
|
-
|
|
7389
|
-
|
|
7323
|
+
const result = mergeGroundingFile(base, ours, theirs);
|
|
7324
|
+
if (result.write !== null)
|
|
7325
|
+
writeFileSync(ours, result.write);
|
|
7326
|
+
// A real conflict or a git error: leave it for a human.
|
|
7327
|
+
if (result.conflict)
|
|
7328
|
+
process.exitCode = 1;
|
|
7390
7329
|
});
|
|
7391
7330
|
// ---- doctor ---------------------------------------------------------------
|
|
7392
7331
|
program
|
|
@@ -7429,11 +7368,9 @@ program
|
|
|
7429
7368
|
const { store, root } = storeFor();
|
|
7430
7369
|
console.log(`Hunch root: ${root}`);
|
|
7431
7370
|
console.log(`git repo: ${isGitRepo(root) ? "yes" : "no"} ${isGitRepo(root) ? `(HEAD ${headSha(root).slice(0, 8)})` : ""}`);
|
|
7432
|
-
// post-commit
|
|
7433
|
-
//
|
|
7434
|
-
//
|
|
7435
|
-
// squash-merge went unrepaired. pre-commit is opt-out (`--no-enforce`),
|
|
7436
|
-
// so its absence is informational only, never a warning.
|
|
7371
|
+
// post-commit and post-merge are the two hooks every setup path installs; a repo
|
|
7372
|
+
// initialized before post-merge existed never got it and had no way to notice.
|
|
7373
|
+
// pre-commit is opt-out (--no-enforce), so its absence is informational only.
|
|
7437
7374
|
if (isGitRepo(root)) {
|
|
7438
7375
|
const report = hookReport(root);
|
|
7439
7376
|
const hooks = hookStatus(root);
|
|
@@ -7515,7 +7452,7 @@ program
|
|
|
7515
7452
|
for (const line of synthesisStatusLines(resolution, process.env))
|
|
7516
7453
|
console.log(line);
|
|
7517
7454
|
const ctxWarning = await maybeWarnOllamaContext(provider.name, process.env);
|
|
7518
|
-
if (ctxWarning)
|
|
7455
|
+
if (ctxWarning !== null)
|
|
7519
7456
|
console.log(ctxWarning);
|
|
7520
7457
|
// The native addons load on first PARSE, not at import, so no other command
|
|
7521
7458
|
// that merely starts up proves the parser works. Doctor is the one place
|
|
@@ -7610,26 +7547,27 @@ function rel(root, p) {
|
|
|
7610
7547
|
}
|
|
7611
7548
|
/** Run the Windows ~/.claude.json drive-letter case-split heal and print what it
|
|
7612
7549
|
* did. Silent + no-op off Windows. A parse refusal is surfaced as a warning, never
|
|
7613
|
-
* thrown out of doctor/init (those commands must still complete).
|
|
7614
|
-
|
|
7550
|
+
* thrown out of doctor/init (those commands must still complete). `say` lets `init`
|
|
7551
|
+
* route the lines into its buffered output (shown with --details; warnings always). */
|
|
7552
|
+
function reportClaudeConfigHeal(say = (text) => console.log(text)) {
|
|
7615
7553
|
let res;
|
|
7616
7554
|
try {
|
|
7617
7555
|
res = healClaudeConfigCaseSplit();
|
|
7618
7556
|
}
|
|
7619
7557
|
catch (e) {
|
|
7620
|
-
|
|
7558
|
+
say(` ⚠ Claude config: ${e.message}`);
|
|
7621
7559
|
return;
|
|
7622
7560
|
}
|
|
7623
7561
|
if (!res.applicable)
|
|
7624
7562
|
return; // non-Windows: the case-split bug can't occur
|
|
7625
7563
|
if (!res.changed) {
|
|
7626
|
-
|
|
7564
|
+
say(dim(`Claude config: no drive-letter project split (${res.file})`));
|
|
7627
7565
|
return;
|
|
7628
7566
|
}
|
|
7629
7567
|
for (const g of res.groups) {
|
|
7630
|
-
|
|
7568
|
+
say(`✓ healed Claude Code project case-split: mirrored [${g.servers.join(", ")}] across ${g.casings.join(" · ")}`);
|
|
7631
7569
|
}
|
|
7632
|
-
|
|
7570
|
+
say(dim(` ↳ backup: ${res.backup}`));
|
|
7633
7571
|
}
|
|
7634
7572
|
function fail(msg) {
|
|
7635
7573
|
console.error(`error: ${msg}`);
|
|
@@ -7650,17 +7588,16 @@ function readStdin() {
|
|
|
7650
7588
|
});
|
|
7651
7589
|
}
|
|
7652
7590
|
/** Absolute edit path → repo-relative, forward-slash (constraint scopes are
|
|
7653
|
-
* forward-slash globs even on Windows)
|
|
7654
|
-
* inside `root`.
|
|
7655
|
-
* which realpath-normalizes both ends
|
|
7656
|
-
*
|
|
7657
|
-
*
|
|
7658
|
-
* callers want the original target back to fail their own match safely. This
|
|
7659
|
-
* adapter converts that "still absolute" signal to "" — the sentinel `onEdit`
|
|
7660
|
-
* and `editTargets` below already treat as "skip, not in this repo". */
|
|
7591
|
+
* forward-slash globs even on Windows). Returns "" when the path isn't resolvably
|
|
7592
|
+
* inside `root`. A thin adapter over the shared `repoRelativeTarget` in core/paths.ts,
|
|
7593
|
+
* which realpath-normalizes both ends but passes an unresolvable absolute path through
|
|
7594
|
+
* unchanged; this converts that "still absolute" result into "", the sentinel that
|
|
7595
|
+
* `onEdit` and `editTargets` treat as "skip, not in this repo". */
|
|
7661
7596
|
function toRepoRel(root, abs) {
|
|
7662
7597
|
const rel = repoRelativeTarget(abs, root);
|
|
7663
|
-
|
|
7598
|
+
if (isAbsolute(rel) || /^[A-Za-z]:/.test(rel))
|
|
7599
|
+
return "";
|
|
7600
|
+
return rel;
|
|
7664
7601
|
}
|
|
7665
7602
|
/** Files a shell command wrote get the grounding the edit tools would have
|
|
7666
7603
|
* delivered before the edit — late, but while the agent can still revise.
|