@davesheffer/hunch 1.46.0 → 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 +151 -321
- package/dist/cli/invocation.d.ts +8 -13
- package/dist/cli/invocation.js +26 -29
- 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.js +9 -9
- 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/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 +45 -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/hooks.d.ts +9 -14
- package/dist/integrations/hooks.js +19 -28
- package/dist/integrations/mergeDriver.js +13 -14
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +60 -73
- 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/package.json +2 -2
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -746,12 +746,11 @@ program
|
|
|
746
746
|
// never rewrites an existing one: `index` runs in CI/release gates on a clean
|
|
747
747
|
// checkout. Older blocks are upgraded by `hunch update` / init / private setup.
|
|
748
748
|
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
|
|
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
|
|
755
754
|
// never be what FIRST hooks a repo that never ran init at all. A STALE
|
|
756
755
|
// post-commit block counts too (issue #315): it proves init ran, so the
|
|
757
756
|
// missing post-merge/post-checkout hooks are still this repo's to heal.
|
|
@@ -825,9 +824,9 @@ program
|
|
|
825
824
|
const commits = logSince(opts.since, root, Number(opts.max));
|
|
826
825
|
const conc = Math.max(1, Math.min(16, Number(opts.concurrency) || 4));
|
|
827
826
|
console.log(`Backfilling from ${commits.length} commit(s) since ${opts.since} (concurrency ${conc})…`);
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
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.
|
|
831
830
|
if (!opts.deep && commits.length > 0) {
|
|
832
831
|
// { root }: the advisory must resolve the SAME provider the synthesis
|
|
833
832
|
// inside syncCommit resolves (persisted local.json preference included) —
|
|
@@ -835,7 +834,7 @@ program
|
|
|
835
834
|
// num_ctx warning for an actual Ollama run (issue #46).
|
|
836
835
|
const ctxProvider = await selectProvider({ root });
|
|
837
836
|
const ctxWarning = await maybeWarnOllamaContext(ctxProvider.name, process.env);
|
|
838
|
-
if (ctxWarning)
|
|
837
|
+
if (ctxWarning !== null)
|
|
839
838
|
console.log(ctxWarning);
|
|
840
839
|
}
|
|
841
840
|
let written = 0, skipped = 0, llm = 0, heuristic = 0;
|
|
@@ -1254,9 +1253,8 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
|
|
|
1254
1253
|
const backLink = commonDir ? separateGitDirLink(commonDir) : "";
|
|
1255
1254
|
const configuredHooks = includeHook ? hooksDir(root) : "";
|
|
1256
1255
|
const hookDir = configuredHooks ? (isAbsolute(configuredHooks) ? configuredHooks : join(root, configuredHooks)) : "";
|
|
1257
|
-
// Setup installs
|
|
1258
|
-
//
|
|
1259
|
-
// 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.
|
|
1260
1258
|
const hookFiles = hookDir ? [join(hookDir, "post-commit"), join(hookDir, "post-merge")] : [];
|
|
1261
1259
|
const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer, backLink] : []), ...hookFiles];
|
|
1262
1260
|
const snapshots = new Map(paths.map((path) => [path, setupPathSnapshot(path)]));
|
|
@@ -1278,7 +1276,8 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
|
|
|
1278
1276
|
markGitignoreWrite: () => mark(codeGitignore),
|
|
1279
1277
|
markTeamWrite: () => mark(teamFile),
|
|
1280
1278
|
markSharedPointerWrite: () => { mark(backLink); mark(sharedPointer); },
|
|
1281
|
-
markHookWrite: () => hookFiles
|
|
1279
|
+
markHookWrite: () => { for (const f of hookFiles)
|
|
1280
|
+
mark(f); },
|
|
1282
1281
|
// Migration is a one-way ownership handoff. Once public records have been
|
|
1283
1282
|
// durably copied into this clone, a later setup failure may restore routing
|
|
1284
1283
|
// files but must not delete the clone that now holds their surviving copy.
|
|
@@ -4910,20 +4909,21 @@ program
|
|
|
4910
4909
|
.requiredOption("--by <new>", "decision id that supersedes it")
|
|
4911
4910
|
.action((oldId, opts) => {
|
|
4912
4911
|
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.
|
|
4912
|
+
// The old decision's own memory home decides which store both ids must live in.
|
|
4916
4913
|
const home = decisionMemoryHome(store, oldId);
|
|
4917
|
-
|
|
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)) {
|
|
4918
4917
|
store.close();
|
|
4919
4918
|
return fail(`decision "${oldId}" not found`);
|
|
4920
4919
|
}
|
|
4921
|
-
|
|
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);
|
|
4922
4922
|
if (!by) {
|
|
4923
4923
|
store.close();
|
|
4924
4924
|
return fail(`--by decision "${opts.by}" not found in the ${home} store that holds "${oldId}"`);
|
|
4925
4925
|
}
|
|
4926
|
-
const closed =
|
|
4926
|
+
const closed = priv ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
|
|
4927
4927
|
if (!closed) {
|
|
4928
4928
|
store.close();
|
|
4929
4929
|
return fail(`decision "${oldId}" cannot supersede itself`);
|
|
@@ -5006,7 +5006,8 @@ program
|
|
|
5006
5006
|
.action(async (value) => {
|
|
5007
5007
|
const root = findRoot();
|
|
5008
5008
|
if (value != null) {
|
|
5009
|
-
const
|
|
5009
|
+
const trimmed = value.trim();
|
|
5010
|
+
const preference = normalizeProviderName(trimmed) || trimmed;
|
|
5010
5011
|
if (!SYNTH_PREFERENCES.includes(preference)) {
|
|
5011
5012
|
return fail(`provider must be one of: ${SYNTH_PREFERENCES.join(", ")}`);
|
|
5012
5013
|
}
|
|
@@ -5019,7 +5020,7 @@ program
|
|
|
5019
5020
|
console.log(`✓ local synthesis preference set to ${preference} (gitignored; it never changes a teammate's billing choice).`);
|
|
5020
5021
|
}
|
|
5021
5022
|
const resolution = await resolveSynthesisProvider({ root });
|
|
5022
|
-
const envValue = normalizeProviderName(process.env.HUNCH_SYNTH_PROVIDER
|
|
5023
|
+
const envValue = normalizeProviderName((process.env.HUNCH_SYNTH_PROVIDER ?? "").trim()) || undefined;
|
|
5023
5024
|
const local = readSynthesisPreference(root);
|
|
5024
5025
|
const hasValidEnv = !!envValue && SYNTH_PREFERENCES.includes(envValue);
|
|
5025
5026
|
console.log(`selected: ${resolution.provider.name} (${resolution.source})`);
|
|
@@ -5536,8 +5537,8 @@ program
|
|
|
5536
5537
|
// so its orientation event keeps asking every actionable entry.
|
|
5537
5538
|
const actionableEsc = deliversPromptEvent(provider) ? [] : (await actionableEscalationsWithAnchors(paths.root, s)).actionable;
|
|
5538
5539
|
if (!decisions.length && !actionableEsc.length) {
|
|
5539
|
-
//
|
|
5540
|
-
//
|
|
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
|
|
5541
5542
|
// (checked against the full store) is enough reason NOT to bail here
|
|
5542
5543
|
// even when the visible decisions list is empty.
|
|
5543
5544
|
if (pipelineEnabled())
|
|
@@ -6237,7 +6238,7 @@ program
|
|
|
6237
6238
|
// ---- escalations (the inline "ask the human" surface) ---------------------
|
|
6238
6239
|
program
|
|
6239
6240
|
.command("escalations")
|
|
6240
|
-
.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.")
|
|
6241
6242
|
.option("--json", "emit the escalation entries as JSON (the VS Code panel's data source)")
|
|
6242
6243
|
.action(async (opts) => {
|
|
6243
6244
|
const { store, root } = storeFor();
|
|
@@ -6247,8 +6248,8 @@ program
|
|
|
6247
6248
|
// Premise decay rides the same inline surface: a decision whose recorded
|
|
6248
6249
|
// reason died is a QUESTION for the human — authority never changes here.
|
|
6249
6250
|
items.push(...premiseEscalations(decisionsForEsc, { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
|
|
6250
|
-
const
|
|
6251
|
-
items.push(...commitRepairEscalations(
|
|
6251
|
+
const queuedRepairs = readActivePendingRepairs(root);
|
|
6252
|
+
items.push(...commitRepairEscalations(queuedRepairs, decisionsForEsc, decisionsForEsc, withheldRewrites(root, queuedRepairs)));
|
|
6252
6253
|
// Constitution moments ride the same inline surface (§59.5.3) — never a queue.
|
|
6253
6254
|
// Fail open: a broken policy store must not take the memory escalations down.
|
|
6254
6255
|
try {
|
|
@@ -6256,10 +6257,10 @@ program
|
|
|
6256
6257
|
items.push(...policyEscalations(new CS(store, root).list().map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
|
|
6257
6258
|
}
|
|
6258
6259
|
catch { /* constitution unavailable — memory escalations still surface */ }
|
|
6259
|
-
//
|
|
6260
|
-
// commit-repair follower whose own resolution says
|
|
6261
|
-
//
|
|
6262
|
-
//
|
|
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.
|
|
6263
6264
|
const actionable = actionableEscalations(items);
|
|
6264
6265
|
if (opts.json) {
|
|
6265
6266
|
console.log(JSON.stringify(items));
|
|
@@ -6271,18 +6272,12 @@ program
|
|
|
6271
6272
|
console.log("✓ Nothing needs your decision — memory is auto-trusted and self-consistent.");
|
|
6272
6273
|
return;
|
|
6273
6274
|
}
|
|
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");
|
|
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`);
|
|
6282
6279
|
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.
|
|
6280
|
+
// A non-actionable follower is shown for context only, so it gets a quieter marker.
|
|
6286
6281
|
console.log(` ${e.actionable === false ? "·" : "⚖"} ${e.question}`);
|
|
6287
6282
|
console.log(` ${dim(e.detail)}`);
|
|
6288
6283
|
console.log(` ${dim("→ " + e.resolution)}\n`);
|
|
@@ -6425,17 +6420,17 @@ program
|
|
|
6425
6420
|
store.close();
|
|
6426
6421
|
}
|
|
6427
6422
|
});
|
|
6428
|
-
// ---- repair-provenance (squash-merge commit
|
|
6423
|
+
// ---- repair-provenance (squash-merge commit-provenance self-repair) -------
|
|
6429
6424
|
program
|
|
6430
6425
|
.command("repair-provenance")
|
|
6431
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.")
|
|
6432
6427
|
.option("--apply", "apply the reviewed queued candidates (requires --expect; does not scan for new matches)")
|
|
6433
6428
|
.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
|
|
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)")
|
|
6436
6431
|
.option("--from-hook", "invoked by the git post-merge hook")
|
|
6437
6432
|
.option("--quiet", "minimal output")
|
|
6438
|
-
.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)")
|
|
6439
6434
|
.action((opts) => {
|
|
6440
6435
|
const { store, root } = storeFor();
|
|
6441
6436
|
try {
|
|
@@ -6450,41 +6445,32 @@ program
|
|
|
6450
6445
|
const parts = opts.range.split("..");
|
|
6451
6446
|
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
6452
6447
|
if (!opts.fromHook)
|
|
6453
|
-
fail(
|
|
6448
|
+
fail(`--range must look like "old..new"`);
|
|
6454
6449
|
return;
|
|
6455
6450
|
}
|
|
6456
|
-
|
|
6451
|
+
oldRef = parts[0];
|
|
6452
|
+
newRef = parts[1];
|
|
6457
6453
|
}
|
|
6458
6454
|
const rangeResolves = revExists(oldRef, root) && revExists(newRef, root);
|
|
6459
6455
|
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
6456
|
if (!opts.fromHook)
|
|
6464
6457
|
fail(`range "${oldRef}..${newRef}" does not resolve in this repository`);
|
|
6465
6458
|
return;
|
|
6466
6459
|
}
|
|
6460
|
+
// An unresolvable DEFAULT range just means no merge happened: detection is skipped below.
|
|
6467
6461
|
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.
|
|
6462
|
+
// One save path per file, so queue semantics cannot drift across write sites.
|
|
6475
6463
|
let queue = readPendingRepairs(root);
|
|
6476
|
-
const
|
|
6477
|
-
queue =
|
|
6464
|
+
const saveQueue = (next) => {
|
|
6465
|
+
queue = next;
|
|
6478
6466
|
writePendingRepairs(root, queue);
|
|
6479
6467
|
};
|
|
6480
|
-
// Tombstones
|
|
6481
|
-
//
|
|
6482
|
-
//
|
|
6483
|
-
// rejected. A genuinely different `to` for the same {id, from} is a new
|
|
6484
|
-
// 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.
|
|
6485
6471
|
let dropped = readDroppedRepairs(root);
|
|
6486
6472
|
const saveDropped = (next) => {
|
|
6487
|
-
dropped =
|
|
6473
|
+
dropped = next;
|
|
6488
6474
|
writeDroppedRepairs(root, dropped);
|
|
6489
6475
|
};
|
|
6490
6476
|
// Review commands act only on the queue that was presented. Validate
|
|
@@ -6498,63 +6484,27 @@ program
|
|
|
6498
6484
|
if (opts.fromHook)
|
|
6499
6485
|
return fail("the post-merge hook may only detect repairs, never approve them.");
|
|
6500
6486
|
}
|
|
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);
|
|
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);
|
|
6526
6493
|
if (swept.length) {
|
|
6527
|
-
|
|
6494
|
+
saveQueue(activeQueue);
|
|
6528
6495
|
if (!opts.quiet)
|
|
6529
6496
|
console.log(`Swept ${swept.length} already-rejected match${swept.length === 1 ? "" : "es"} from the queue: ${swept.map((r) => r.id).join(", ")}`);
|
|
6530
6497
|
}
|
|
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)));
|
|
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)));
|
|
6556
6506
|
if (!opts.quiet)
|
|
6557
|
-
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(", ")}`);
|
|
6558
6508
|
}
|
|
6559
6509
|
const candidates = rangeResolves && !opts.apply && !opts.drop ? mergeRangeChanges(oldRef, newRef, root) : [];
|
|
6560
6510
|
// Orphaned means HEAD no longer reaches the commit either: a historical
|
|
@@ -6562,82 +6512,42 @@ program
|
|
|
6562
6512
|
const orphaned = candidates.length ? orphanedCommitDecisions(decisions, (sha) => commitRepairStatusAgainst(sha, [newRef, "HEAD"], root)) : [];
|
|
6563
6513
|
const patchIds = orphaned.length ? commitPatchIds([...orphaned.map((d) => d.commit), ...candidates.map((c) => c.sha)], root) : new Map();
|
|
6564
6514
|
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));
|
|
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));
|
|
6578
6519
|
if (opts.drop) {
|
|
6579
6520
|
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
6521
|
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.
|
|
6522
|
+
saveQueue(queue.filter((r) => r !== target));
|
|
6595
6523
|
if (target) {
|
|
6596
6524
|
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));
|
|
6525
|
+
// Re-sweep so an identical sibling cannot be applied later in this invocation.
|
|
6526
|
+
saveQueue(withoutDropped(queue, dropped));
|
|
6605
6527
|
}
|
|
6606
6528
|
if (!opts.quiet) {
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
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.`);
|
|
6610
6533
|
}
|
|
6611
6534
|
if (!opts.apply)
|
|
6612
6535
|
return;
|
|
6613
6536
|
}
|
|
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.
|
|
6537
|
+
// Deliberately before the --only messages.
|
|
6619
6538
|
if (!queue.length) {
|
|
6620
6539
|
if (!opts.quiet)
|
|
6621
6540
|
console.log("✓ Nothing to repair — no orphaned commit reference matched unambiguously, and nothing queued from an earlier run.");
|
|
6622
6541
|
return;
|
|
6623
6542
|
}
|
|
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
6543
|
const toApply = opts.only ? queue.filter((r) => r.id === opts.only) : queue;
|
|
6629
6544
|
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.
|
|
6545
|
+
if (onlySwept) {
|
|
6634
6546
|
if (!opts.quiet)
|
|
6635
6547
|
console.log(`"${opts.only}" was already rejected via --drop — nothing left to do.`);
|
|
6636
6548
|
return;
|
|
6637
6549
|
}
|
|
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).
|
|
6550
|
+
if (onlyPruned) {
|
|
6641
6551
|
if (!opts.quiet)
|
|
6642
6552
|
console.log(`"${opts.only}" was pruned earlier in this run — nothing left to do.`);
|
|
6643
6553
|
return;
|
|
@@ -6646,79 +6556,32 @@ program
|
|
|
6646
6556
|
fail(`no queued or matched entry for "${opts.only}"`);
|
|
6647
6557
|
return;
|
|
6648
6558
|
}
|
|
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.
|
|
6559
|
+
// Apply-ability is computed before the dry-run branch so preview and apply agree.
|
|
6670
6560
|
const visibleIds = new Set(decisions.map((d) => d.id));
|
|
6671
|
-
|
|
6672
|
-
|
|
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);
|
|
6673
6566
|
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
6567
|
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"
|
|
6568
|
+
const isChosen = (r) => pickRewrite(plan, r.id) === r;
|
|
6569
|
+
const stayReason = (r) => !visibleIds.has(r.id) ? "invisible"
|
|
6707
6570
|
: withheldSet.has(r) ? "withheld"
|
|
6708
|
-
: applicableSet.has(r) && !
|
|
6571
|
+
: applicableSet.has(r) && !isChosen(r) ? "duplicate"
|
|
6709
6572
|
: null;
|
|
6710
|
-
const staysQueued = (r) =>
|
|
6573
|
+
const staysQueued = (r) => stayReason(r) !== null;
|
|
6711
6574
|
if (!opts.apply) {
|
|
6712
6575
|
if (!opts.quiet) {
|
|
6713
|
-
const
|
|
6714
|
-
console.log(
|
|
6715
|
-
? `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:`
|
|
6716
6579
|
: `Would repair ${toApply.length} commit reference(s):`);
|
|
6717
6580
|
// Match strength, so a reviewer can tell an identical change from a
|
|
6718
6581
|
// file-overlap guess before applying it into shared memory (#450).
|
|
6719
6582
|
const previewPatchIds = commitPatchIds(toApply.flatMap((r) => [r.from, r.to]), root);
|
|
6720
6583
|
for (const r of toApply) {
|
|
6721
|
-
const reason =
|
|
6584
|
+
const reason = stayReason(r);
|
|
6722
6585
|
const samePatch = !!previewPatchIds.get(r.from) && previewPatchIds.get(r.from) === previewPatchIds.get(r.to);
|
|
6723
6586
|
const label = reason === "invisible" ? "not visible this run — --apply would leave it queued"
|
|
6724
6587
|
: reason === "withheld" ? "proposed replacement commit doesn't resolve in this repository — --apply would leave it queued"
|
|
@@ -6736,7 +6599,7 @@ program
|
|
|
6736
6599
|
}
|
|
6737
6600
|
return;
|
|
6738
6601
|
}
|
|
6739
|
-
const
|
|
6602
|
+
const homes = new Set();
|
|
6740
6603
|
const appliedIds = new Set();
|
|
6741
6604
|
for (const d of decisions) {
|
|
6742
6605
|
const healed = repairDecisionCommit(d, plan);
|
|
@@ -6744,47 +6607,25 @@ program
|
|
|
6744
6607
|
continue;
|
|
6745
6608
|
const home = decisionMemoryHome(store, d.id);
|
|
6746
6609
|
store.putWhereItLives("decisions", healed);
|
|
6747
|
-
|
|
6610
|
+
homes.add(home);
|
|
6748
6611
|
appliedIds.add(d.id);
|
|
6749
6612
|
}
|
|
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)));
|
|
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));
|
|
6770
6618
|
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);
|
|
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);
|
|
6780
6623
|
if (!opts.quiet) {
|
|
6781
6624
|
if (invisibleIds.length) {
|
|
6782
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.`);
|
|
6783
6626
|
}
|
|
6784
6627
|
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";
|
|
6628
|
+
const subject = withheldIds.length === 1 ? "an entry whose proposed replacement commit doesn't" : "entries whose proposed replacement commits don't";
|
|
6788
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.`);
|
|
6789
6630
|
}
|
|
6790
6631
|
}
|
|
@@ -6792,37 +6633,28 @@ program
|
|
|
6792
6633
|
}
|
|
6793
6634
|
store.reindex();
|
|
6794
6635
|
const rangeLabel = rangeResolves ? `${oldRef}..${newRef}` : "queued";
|
|
6795
|
-
pumpMemoryHomes(store, root,
|
|
6636
|
+
pumpMemoryHomes(store, root, homes, `hunch: repair ${appliedIds.size} commit reference(s) after squash-merge (${rangeLabel})`);
|
|
6796
6637
|
if (!opts.quiet) {
|
|
6797
6638
|
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))
|
|
6639
|
+
for (const r of applicable) {
|
|
6640
|
+
// A losing same-id sibling must not be listed.
|
|
6641
|
+
if (appliedIds.has(r.id) && isChosen(r))
|
|
6804
6642
|
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
6643
|
}
|
|
6812
|
-
|
|
6813
|
-
|
|
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.`);
|
|
6814
6654
|
}
|
|
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(", ")}`);
|
|
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(", ")}`);
|
|
6826
6658
|
}
|
|
6827
6659
|
}
|
|
6828
6660
|
}
|
|
@@ -7362,13 +7194,13 @@ program
|
|
|
7362
7194
|
console.log(`· ${f.id} — ${f.detail}`);
|
|
7363
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`);
|
|
7364
7196
|
}
|
|
7365
|
-
// Every drift kind
|
|
7197
|
+
// Every drift kind must heal here.
|
|
7366
7198
|
const commitUnresolvable = kind("commit-unresolvable");
|
|
7367
7199
|
if (commitUnresolvable.length) {
|
|
7368
7200
|
console.log(`${commitUnresolvable.length} decision(s) cite a commit that no longer resolves in this repository:\n`);
|
|
7369
7201
|
for (const f of commitUnresolvable)
|
|
7370
7202
|
console.log(`· ${f.id} — ${f.detail}`);
|
|
7371
|
-
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`);
|
|
7372
7204
|
}
|
|
7373
7205
|
const fenceUnclosed = kind("doc-fence-unclosed");
|
|
7374
7206
|
if (fenceUnclosed.length) {
|
|
@@ -7479,20 +7311,21 @@ program
|
|
|
7479
7311
|
process.exitCode = 1; // conflict markers remain → block the commit for review
|
|
7480
7312
|
}
|
|
7481
7313
|
});
|
|
7482
|
-
// ---- merge-driver-grounding (internal
|
|
7314
|
+
// ---- merge-driver-grounding (internal) -----------------------------------
|
|
7483
7315
|
program
|
|
7484
7316
|
.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
|
|
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)")
|
|
7490
7322
|
.action((base, ours, theirs) => {
|
|
7491
|
-
const
|
|
7492
|
-
if (
|
|
7493
|
-
writeFileSync(ours,
|
|
7494
|
-
|
|
7495
|
-
|
|
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;
|
|
7496
7329
|
});
|
|
7497
7330
|
// ---- doctor ---------------------------------------------------------------
|
|
7498
7331
|
program
|
|
@@ -7535,11 +7368,9 @@ program
|
|
|
7535
7368
|
const { store, root } = storeFor();
|
|
7536
7369
|
console.log(`Hunch root: ${root}`);
|
|
7537
7370
|
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.
|
|
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.
|
|
7543
7374
|
if (isGitRepo(root)) {
|
|
7544
7375
|
const report = hookReport(root);
|
|
7545
7376
|
const hooks = hookStatus(root);
|
|
@@ -7621,7 +7452,7 @@ program
|
|
|
7621
7452
|
for (const line of synthesisStatusLines(resolution, process.env))
|
|
7622
7453
|
console.log(line);
|
|
7623
7454
|
const ctxWarning = await maybeWarnOllamaContext(provider.name, process.env);
|
|
7624
|
-
if (ctxWarning)
|
|
7455
|
+
if (ctxWarning !== null)
|
|
7625
7456
|
console.log(ctxWarning);
|
|
7626
7457
|
// The native addons load on first PARSE, not at import, so no other command
|
|
7627
7458
|
// that merely starts up proves the parser works. Doctor is the one place
|
|
@@ -7757,17 +7588,16 @@ function readStdin() {
|
|
|
7757
7588
|
});
|
|
7758
7589
|
}
|
|
7759
7590
|
/** 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". */
|
|
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". */
|
|
7768
7596
|
function toRepoRel(root, abs) {
|
|
7769
7597
|
const rel = repoRelativeTarget(abs, root);
|
|
7770
|
-
|
|
7598
|
+
if (isAbsolute(rel) || /^[A-Za-z]:/.test(rel))
|
|
7599
|
+
return "";
|
|
7600
|
+
return rel;
|
|
7771
7601
|
}
|
|
7772
7602
|
/** Files a shell command wrote get the grounding the edit tools would have
|
|
7773
7603
|
* delivered before the edit — late, but while the agent can still revise.
|