@davesheffer/hunch 1.45.1 → 1.46.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 +165 -33
- package/dist/cli/invocation.d.ts +14 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.js +2 -1
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +82 -26
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/delivery.d.ts +10 -0
- package/dist/core/delivery.js +117 -9
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/pipeline.d.ts +9 -2
- package/dist/core/pipeline.js +13 -3
- package/dist/core/procstart.d.ts +17 -3
- package/dist/core/procstart.js +26 -5
- package/dist/core/projectDna.js +11 -1
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/taskSelection.d.ts +1 -0
- package/dist/core/taskSelection.js +132 -11
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/core/whyRecord.d.ts +47 -0
- package/dist/core/whyRecord.js +105 -0
- package/dist/extractors/git.d.ts +32 -0
- package/dist/extractors/git.js +206 -94
- package/dist/extractors/indexer.js +42 -6
- package/dist/extractors/languages.d.ts +9 -0
- package/dist/extractors/languages.js +28 -0
- package/dist/extractors/parse.d.ts +20 -0
- package/dist/extractors/parse.js +81 -5
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +4 -2
- package/dist/integrations/hooks.js +12 -2
- package/dist/integrations/mergeDriver.js +5 -3
- 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 +6 -4
- package/dist/integrations/uninstall.d.ts +17 -4
- package/dist/integrations/uninstall.js +35 -13
- package/dist/mcp/server.js +179 -30
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +6 -5
- package/dist/serve/writelock.js +26 -21
- package/dist/store/hunchStore.d.ts +12 -1
- package/dist/store/hunchStore.js +86 -4
- package/dist/store/jsonStore.d.ts +24 -4
- package/dist/store/jsonStore.js +75 -26
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.js +26 -78
- package/dist/synthesis/synthesize.js +1 -1
- package/package.json +2 -2
- 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
|
-
import { registerServeCommands } from "./serve.js";
|
|
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";
|
|
@@ -43,7 +46,7 @@ import { isParserLoadError, loadNativeTreeSitter } from "../extractors/nativeTre
|
|
|
43
46
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
44
47
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
45
48
|
import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
|
|
46
|
-
import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeCommits, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
|
|
49
|
+
import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeCommits, revExists, revParse, commitAndPushHunch, pullHunchStatus, pullHunchStatusDetailed, describePullFailure, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
|
|
47
50
|
import { runbookDraft } from "../core/runbookDraft.js";
|
|
48
51
|
import { parseMemoryLog } from "../core/memorylog.js";
|
|
49
52
|
import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.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, optOutMessage, planUninstall } from "../integrations/uninstall.js";
|
|
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";
|
|
@@ -102,7 +105,7 @@ import { renderRecalledLine } from "../core/taskReportRender.js";
|
|
|
102
105
|
import { closeHookTask, hookReportTaskId, isNotificationPrompt, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
|
|
103
106
|
import { persistTaskRecord } from "../core/taskRecord.js";
|
|
104
107
|
import { recordHookObservation } from "../core/hookObservations.js";
|
|
105
|
-
import { contextHookOutput, deliversPromptEvent, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
|
|
108
|
+
import { contextHookOutput, deliversCommandOutcome, deliversPromptEvent, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
|
|
106
109
|
import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, onLessonsDelivered, lessonReminder, unverifiedNag, } from "../core/pipeline.js";
|
|
107
110
|
import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
|
|
108
111
|
import { planAutoReview, planMutations } from "../core/autoreview.js";
|
|
@@ -147,6 +150,7 @@ import { partitionOf, stateHomeFor } from "../store/stateBinding.js";
|
|
|
147
150
|
import { scopePath } from "../core/stateContract.js";
|
|
148
151
|
import { movePublicMemoryToPrivate } from "../store/privateMigrate.js";
|
|
149
152
|
import { ENTITY_KINDS } from "../core/types.js";
|
|
153
|
+
import { formatWhyRecord } from "../core/whyRecord.js";
|
|
150
154
|
import { planCompaction } from "../store/compact.js";
|
|
151
155
|
import { repairDecisionReference } from "../core/refrepair.js";
|
|
152
156
|
import { resolveInvocation, dim, synthesisStatusLines, maybeWarnOllamaContext } from "./invocation.js";
|
|
@@ -187,6 +191,7 @@ registerTaskReportCommands(program, () => { const { store, root } = storeFor();
|
|
|
187
191
|
registerServeCommands(program);
|
|
188
192
|
registerStateCommands(program);
|
|
189
193
|
registerUpdateCommand(program);
|
|
194
|
+
registerVerifyCommand(program);
|
|
190
195
|
registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
|
|
191
196
|
const { store, root } = storeFor();
|
|
192
197
|
if (!repositoryUsesRemote(root, `https://github.com/${repository}.git`)) {
|
|
@@ -227,6 +232,103 @@ registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
|
|
|
227
232
|
// run: a leftover driver that exits without writing %A would let git drop `theirs`.
|
|
228
233
|
const OPT_OUT_ALLOWED = new Set(["uninstall", "doctor", "help", "merge-driver", "merge-driver-grounding"]);
|
|
229
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
|
+
}
|
|
230
332
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
231
333
|
let top = actionCommand;
|
|
232
334
|
while (top.parent && top.parent !== program)
|
|
@@ -234,10 +336,26 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
|
234
336
|
const name = top.name();
|
|
235
337
|
if (OPT_OUT_ALLOWED.has(name))
|
|
236
338
|
return;
|
|
237
|
-
|
|
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)
|
|
238
356
|
return;
|
|
239
357
|
if (name !== "hook")
|
|
240
|
-
console.error(optOutMessage());
|
|
358
|
+
console.error(optOutMessage(marker));
|
|
241
359
|
process.exit(OPT_OUT_QUIET.has(name) ? 0 : 1);
|
|
242
360
|
});
|
|
243
361
|
// Read an already-known update and schedule any registry refresh in a detached
|
|
@@ -301,6 +419,7 @@ function openTeamStore(root, opts = {}) {
|
|
|
301
419
|
// from their durable local overlay when the network is temporarily down, but a
|
|
302
420
|
// strict guard must prove it saw the advertised remote before it may pass.
|
|
303
421
|
let pullStatus = null;
|
|
422
|
+
let pullDetail;
|
|
304
423
|
if (teamAdvertised && store.mode === "shared" && store.privateDir) {
|
|
305
424
|
const remote = advertisedTeamRemoteContract(root, dirname(store.privateDir));
|
|
306
425
|
if (!remote || !remote.verify()) {
|
|
@@ -309,10 +428,9 @@ function openTeamStore(root, opts = {}) {
|
|
|
309
428
|
throw new Error("the advertised team memory route could not be pinned for this command");
|
|
310
429
|
}
|
|
311
430
|
pinSharedRemote(store, remote);
|
|
312
|
-
pullStatus =
|
|
313
|
-
timeoutMs: 5_000,
|
|
431
|
+
({ status: pullStatus, detail: pullDetail } = pullHunchStatusDetailed(store.privateDir, {
|
|
314
432
|
remote,
|
|
315
|
-
});
|
|
433
|
+
}));
|
|
316
434
|
// The route may be coherently rewritten while the bounded pull is blocked.
|
|
317
435
|
// No handler — including an ordinary non-strict read — may run after the
|
|
318
436
|
// graph epoch that admitted this command stops verifying.
|
|
@@ -322,12 +440,12 @@ function openTeamStore(root, opts = {}) {
|
|
|
322
440
|
throw new Error("the advertised team memory route changed while this command was refreshing; refusing to serve or mutate stale memory");
|
|
323
441
|
}
|
|
324
442
|
}
|
|
325
|
-
return { store, teamWired, teamPullStatus: opts.requireFreshTeamMemory ? pullStatus : null };
|
|
443
|
+
return { store, teamWired, teamPullStatus: opts.requireFreshTeamMemory ? pullStatus : null, teamPullDetail: opts.requireFreshTeamMemory ? pullDetail : undefined };
|
|
326
444
|
}
|
|
327
445
|
function storeFor(opts = {}) {
|
|
328
446
|
const root = findRoot();
|
|
329
|
-
const { store, teamPullStatus } = openTeamStore(root, opts);
|
|
330
|
-
return { store, root, teamPullStatus };
|
|
447
|
+
const { store, teamPullStatus, teamPullDetail } = openTeamStore(root, opts);
|
|
448
|
+
return { store, root, teamPullStatus, teamPullDetail };
|
|
331
449
|
}
|
|
332
450
|
function pumpMemoryHome(store, root, home, message) {
|
|
333
451
|
flushMemoryHome(store, hunchPaths(root).hunch, home, message);
|
|
@@ -539,8 +657,8 @@ program
|
|
|
539
657
|
}
|
|
540
658
|
// Windows self-heal: if an earlier global `claude mcp add` left a drive-letter
|
|
541
659
|
// case-split in ~/.claude.json, merge it so hunch resolves under either casing.
|
|
542
|
-
// No-op (silent) off Windows.
|
|
543
|
-
reportClaudeConfigHeal();
|
|
660
|
+
// No-op (silent) off Windows; the no-op line belongs to --details (#493).
|
|
661
|
+
reportClaudeConfigHeal(say);
|
|
544
662
|
// Worktree-seamless: register any configured overlay at the git common dir so EVERY
|
|
545
663
|
// worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
|
|
546
664
|
// and note when we're initializing inside a linked worktree (memory is shared, not separate).
|
|
@@ -971,13 +1089,12 @@ function discoverSharedRemote(remoteUrl) {
|
|
|
971
1089
|
&& remoteUrl.startsWith(prefix))
|
|
972
1090
|
return null;
|
|
973
1091
|
}
|
|
974
|
-
|
|
1092
|
+
// Bounded so a timeout kills git's whole tree, not just the root (#498).
|
|
1093
|
+
const listed = boundedSpawnSync("git", [
|
|
975
1094
|
"-C", probe,
|
|
976
1095
|
"-c", `core.hooksPath=${hooksDir}`,
|
|
977
1096
|
"ls-remote", "--refs", "--heads", "--upload-pack=git-upload-pack", remoteUrl,
|
|
978
1097
|
], {
|
|
979
|
-
encoding: "utf8",
|
|
980
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
981
1098
|
env: boundedTeamGitEnv(),
|
|
982
1099
|
timeout: 15_000,
|
|
983
1100
|
});
|
|
@@ -1054,7 +1171,6 @@ function configureExistingValidatedOverlay(destRoot, codeRoot, requestedRemote,
|
|
|
1054
1171
|
}
|
|
1055
1172
|
else {
|
|
1056
1173
|
const pulled = pullHunchStatus(hunchDir, {
|
|
1057
|
-
timeoutMs: 5_000,
|
|
1058
1174
|
remote: contract,
|
|
1059
1175
|
allowUnrelatedHistories: true,
|
|
1060
1176
|
});
|
|
@@ -1311,7 +1427,7 @@ function configureOverlay(dir, opts, mode) {
|
|
|
1311
1427
|
// Anchor the default store at the MAIN worktree root: a linked worktree can be
|
|
1312
1428
|
// `git worktree remove`d, which would take the store (and every other worktree's
|
|
1313
1429
|
// absolute pointer to it) down with it. An explicit [dir] still resolves from here.
|
|
1314
|
-
const anchor =
|
|
1430
|
+
const anchor = overlayAnchor(root);
|
|
1315
1431
|
if (opts.repo) {
|
|
1316
1432
|
const dest = join(anchor, ".hunch-private");
|
|
1317
1433
|
// Canonicalize once in the invocation context so clone, attach, fetch, and
|
|
@@ -3783,7 +3899,7 @@ program
|
|
|
3783
3899
|
program
|
|
3784
3900
|
.command("why")
|
|
3785
3901
|
.description("Explain why a file/symbol is the way it is (decisions, bugs, constraints).")
|
|
3786
|
-
.argument("<target>", "file path or
|
|
3902
|
+
.argument("<target>", "file path, symbol name, or record id (dec_/con_/bug_/fnd_/cmp_)")
|
|
3787
3903
|
.option("--as-of <ref>", "time-travel: what was believed as of a commit/tag/branch (e.g. v0.7.0, HEAD~5)")
|
|
3788
3904
|
.action((target, opts) => {
|
|
3789
3905
|
const { store, root } = storeFor();
|
|
@@ -3791,6 +3907,13 @@ program
|
|
|
3791
3907
|
if (opts.asOf && !asOf)
|
|
3792
3908
|
return fail(`could not resolve --as-of "${opts.asOf}" to a commit (need a git repo and a valid ref)`);
|
|
3793
3909
|
const w = store.why(target, { asOf });
|
|
3910
|
+
if (w.byId?.record) {
|
|
3911
|
+
if (asOf)
|
|
3912
|
+
console.log(`Why "${target}" (as of ${opts.asOf} — ${asOf.slice(0, 10)}):\n`);
|
|
3913
|
+
console.log(formatWhyRecord(w.byId).join("\n"));
|
|
3914
|
+
store.close();
|
|
3915
|
+
return;
|
|
3916
|
+
}
|
|
3794
3917
|
const staleIds = new Set(store.staleness((f) => lastChangeDate(f, root)).map((s) => s.id));
|
|
3795
3918
|
const drift = (id) => (staleIds.has(id) ? " ⚠STALE" : "");
|
|
3796
3919
|
console.log(asOf ? `Why "${target}" (as of ${opts.asOf} — ${asOf.slice(0, 10)}):\n` : `Why "${target}":\n`);
|
|
@@ -3810,7 +3933,7 @@ program
|
|
|
3810
3933
|
console.log(` • ${b.id} [${b.status}] ${b.title} — ${b.root_cause}`);
|
|
3811
3934
|
}
|
|
3812
3935
|
if (!w.decisions.length && !w.constraints.length && !w.bugs.length) {
|
|
3813
|
-
console.log("(No recorded decisions/bugs/constraints yet. Try `hunch backfill` or make a commit.)");
|
|
3936
|
+
console.log(w.byId ? formatWhyRecord(w.byId).join("\n") : "(No recorded decisions/bugs/constraints yet. Try `hunch backfill` or make a commit.)");
|
|
3814
3937
|
}
|
|
3815
3938
|
if (w.symbols.length)
|
|
3816
3939
|
console.log(`\nSYMBOLS: ${w.symbols.map((s) => `${s.name} [fan-in ${s.metrics.fan_in}, churn ${s.metrics.churn_90d}]`).join(", ")}`);
|
|
@@ -4233,9 +4356,10 @@ program
|
|
|
4233
4356
|
// interleaved section prints are suppressed for this format.
|
|
4234
4357
|
const sarif = opts.format === "sarif";
|
|
4235
4358
|
const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
|
|
4236
|
-
const { store, root, teamPullStatus } = storeFor({ requireFreshTeamMemory: !!opts.strict && !opts.publicOnly });
|
|
4359
|
+
const { store, root, teamPullStatus, teamPullDetail } = storeFor({ requireFreshTeamMemory: !!opts.strict && !opts.publicOnly });
|
|
4237
4360
|
const teamFreshnessFailure = teamPullStatus !== null && !teamPullIsFresh(teamPullStatus)
|
|
4238
4361
|
? `strict check could not refresh the advertised team memory (${teamPullStatus}); refusing a stale pass` +
|
|
4362
|
+
(teamPullDetail ? ` (${describePullFailure(teamPullDetail)})` : "") +
|
|
4239
4363
|
(teamPullStatus === "unconfigured" ? " — the shared memory remote has no published branch while this clone has memory; publish it with `hunch shared --sync`" : "")
|
|
4240
4364
|
: null;
|
|
4241
4365
|
// An empty team remote is in sync with a never-committed clone, but it is also what
|
|
@@ -5466,7 +5590,11 @@ program
|
|
|
5466
5590
|
// before implementation. Firm/strict may deny two edits per prompt, then
|
|
5467
5591
|
// fail open so a malformed or unavailable probe can never deadlock work.
|
|
5468
5592
|
if ((firmness === "firm" || firmness === "strict") && evt.session_id && pipelineEnabled() && targets.some((t) => isProductPath(t.target))) {
|
|
5469
|
-
const baseline = beforeEditProbeVerdict(loadPipelineState(evt.session_id));
|
|
5593
|
+
const baseline = beforeEditProbeVerdict(loadPipelineState(evt.session_id), { commandOutcomes: deliversCommandOutcome(provider) });
|
|
5594
|
+
// Unverifiable on this host: the edit proceeds and the hook says why on
|
|
5595
|
+
// stderr (stdout stays free for the grounding/deny response below).
|
|
5596
|
+
if (baseline.unverifiable && baseline.reason)
|
|
5597
|
+
process.stderr.write(`${baseline.reason}\n`);
|
|
5470
5598
|
if (baseline.block) {
|
|
5471
5599
|
savePipelineState(evt.session_id, baseline.state);
|
|
5472
5600
|
emitDeny(provider, baseline.reason ?? "Hunch evidence gate — establish the pre-edit probe baseline first.");
|
|
@@ -6069,12 +6197,15 @@ program
|
|
|
6069
6197
|
return;
|
|
6070
6198
|
}
|
|
6071
6199
|
const res = target.persistMigration();
|
|
6072
|
-
writeManifest(paths,
|
|
6200
|
+
writeManifest(paths, res.version); // held at `from` while any record failed to migrate
|
|
6073
6201
|
store.reindex();
|
|
6074
|
-
pumpMemoryHome(store, root, store.unified ? "private" : "public", `hunch: migrate schema v${from} to v${
|
|
6075
|
-
console.log(`✓ Migrated v${from} → v${
|
|
6202
|
+
pumpMemoryHome(store, root, store.unified ? "private" : "public", `hunch: migrate schema v${from} to v${res.version}`);
|
|
6203
|
+
console.log(`✓ Migrated v${from} → v${res.version}: ${res.migrated} record(s) upgraded.`);
|
|
6076
6204
|
if (res.skipped) {
|
|
6077
|
-
console.warn(`⚠ ${res.skipped} record(s) could
|
|
6205
|
+
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}.`);
|
|
6206
|
+
}
|
|
6207
|
+
if (res.invalid) {
|
|
6208
|
+
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.`);
|
|
6078
6209
|
}
|
|
6079
6210
|
store.close();
|
|
6080
6211
|
});
|
|
@@ -7585,26 +7716,27 @@ function rel(root, p) {
|
|
|
7585
7716
|
}
|
|
7586
7717
|
/** Run the Windows ~/.claude.json drive-letter case-split heal and print what it
|
|
7587
7718
|
* did. Silent + no-op off Windows. A parse refusal is surfaced as a warning, never
|
|
7588
|
-
* thrown out of doctor/init (those commands must still complete).
|
|
7589
|
-
|
|
7719
|
+
* thrown out of doctor/init (those commands must still complete). `say` lets `init`
|
|
7720
|
+
* route the lines into its buffered output (shown with --details; warnings always). */
|
|
7721
|
+
function reportClaudeConfigHeal(say = (text) => console.log(text)) {
|
|
7590
7722
|
let res;
|
|
7591
7723
|
try {
|
|
7592
7724
|
res = healClaudeConfigCaseSplit();
|
|
7593
7725
|
}
|
|
7594
7726
|
catch (e) {
|
|
7595
|
-
|
|
7727
|
+
say(` ⚠ Claude config: ${e.message}`);
|
|
7596
7728
|
return;
|
|
7597
7729
|
}
|
|
7598
7730
|
if (!res.applicable)
|
|
7599
7731
|
return; // non-Windows: the case-split bug can't occur
|
|
7600
7732
|
if (!res.changed) {
|
|
7601
|
-
|
|
7733
|
+
say(dim(`Claude config: no drive-letter project split (${res.file})`));
|
|
7602
7734
|
return;
|
|
7603
7735
|
}
|
|
7604
7736
|
for (const g of res.groups) {
|
|
7605
|
-
|
|
7737
|
+
say(`✓ healed Claude Code project case-split: mirrored [${g.servers.join(", ")}] across ${g.casings.join(" · ")}`);
|
|
7606
7738
|
}
|
|
7607
|
-
|
|
7739
|
+
say(dim(` ↳ backup: ${res.backup}`));
|
|
7608
7740
|
}
|
|
7609
7741
|
function fail(msg) {
|
|
7610
7742
|
console.error(`error: ${msg}`);
|
package/dist/cli/invocation.d.ts
CHANGED
|
@@ -41,3 +41,17 @@ export declare function classifyEntry(entry: string): {
|
|
|
41
41
|
installed: boolean;
|
|
42
42
|
};
|
|
43
43
|
export declare function resolveInvocation(): ResolvedInvocation;
|
|
44
|
+
/** The command prefix for the git merge drivers. git runs a driver with the
|
|
45
|
+
* repository being merged as its cwd, so the dev-mode `npx tsx <entry>` would
|
|
46
|
+
* resolve tsx from THAT repo (or fetch it from the registry), not from Hunch's
|
|
47
|
+
* own checkout (#492). In dev mode, launch the absolute node binary against
|
|
48
|
+
* this checkout's tsx cli.mjs, the same shape selfBuildInvocation gives the
|
|
49
|
+
* git hooks (src/integrations/hooks.ts); every other invocation (published,
|
|
50
|
+
* dist, or a caller-supplied one) passes through unchanged. */
|
|
51
|
+
export declare function mergeDriverShell(invShell: string): string;
|
|
52
|
+
/** Filesystem paths, never a file URL: git expands `%`-escapes in the driver
|
|
53
|
+
* command, so a percent-encoded URL (any non-ASCII checkout path) would be
|
|
54
|
+
* corrupted. POSIX single quotes keep `$`, backticks and `"` literal under the
|
|
55
|
+
* /bin/sh git runs drivers with on every platform, and a literal `%` in a path
|
|
56
|
+
* is doubled so git expands it back to one. */
|
|
57
|
+
export declare function devMergeDriverShell(execPath: string, tsxCli: string, entry: string): string;
|
package/dist/cli/invocation.js
CHANGED
|
@@ -125,4 +125,27 @@ export function resolveInvocation() {
|
|
|
125
125
|
installed,
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
|
+
/** The command prefix for the git merge drivers. git runs a driver with the
|
|
129
|
+
* repository being merged as its cwd, so the dev-mode `npx tsx <entry>` would
|
|
130
|
+
* resolve tsx from THAT repo (or fetch it from the registry), not from Hunch's
|
|
131
|
+
* own checkout (#492). In dev mode, launch the absolute node binary against
|
|
132
|
+
* this checkout's tsx cli.mjs, the same shape selfBuildInvocation gives the
|
|
133
|
+
* git hooks (src/integrations/hooks.ts); every other invocation (published,
|
|
134
|
+
* dist, or a caller-supplied one) passes through unchanged. */
|
|
135
|
+
export function mergeDriverShell(invShell) {
|
|
136
|
+
const inv = resolveInvocation();
|
|
137
|
+
if (inv.installed || !inv.shell.startsWith("npx tsx ") || invShell !== inv.shell)
|
|
138
|
+
return invShell;
|
|
139
|
+
const entry = fileURLToPath(import.meta.url).replace(/invocation\.ts$/, "index.ts");
|
|
140
|
+
return devMergeDriverShell(process.execPath, fileURLToPath(import.meta.resolve("tsx/cli")), entry);
|
|
141
|
+
}
|
|
142
|
+
/** Filesystem paths, never a file URL: git expands `%`-escapes in the driver
|
|
143
|
+
* command, so a percent-encoded URL (any non-ASCII checkout path) would be
|
|
144
|
+
* corrupted. POSIX single quotes keep `$`, backticks and `"` literal under the
|
|
145
|
+
* /bin/sh git runs drivers with on every platform, and a literal `%` in a path
|
|
146
|
+
* is doubled so git expands it back to one. */
|
|
147
|
+
export function devMergeDriverShell(execPath, tsxCli, entry) {
|
|
148
|
+
const quote = (s) => `'${s.replace(/'/g, "'\\''")}'`.replace(/%/g, "%%");
|
|
149
|
+
return [execPath, tsxCli, entry].map(quote).join(" ");
|
|
150
|
+
}
|
|
128
151
|
//# sourceMappingURL=invocation.js.map
|
package/dist/cli/serve.d.ts
CHANGED
|
@@ -13,4 +13,6 @@ export declare function compactPartition(root: string, scope: {
|
|
|
13
13
|
floor_seq: number;
|
|
14
14
|
head_seq: number;
|
|
15
15
|
}>;
|
|
16
|
+
/** The serve config file used when no --config is given, resolved against the cwd. */
|
|
17
|
+
export declare const DEFAULT_CONFIG = "hunch-serve.json";
|
|
16
18
|
export declare function registerServeCommands(program: Command): void;
|
package/dist/cli/serve.js
CHANGED
|
@@ -32,8 +32,9 @@ export async function compactPartition(root, scope, keep, lockOptions = {}) {
|
|
|
32
32
|
store.close();
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
+
/** The serve config file used when no --config is given, resolved against the cwd. */
|
|
36
|
+
export const DEFAULT_CONFIG = "hunch-serve.json";
|
|
35
37
|
export function registerServeCommands(program) {
|
|
36
|
-
const DEFAULT_CONFIG = "hunch-serve.json";
|
|
37
38
|
const serve = program.command("serve")
|
|
38
39
|
.description("Serve nuryel.state/1 over HTTP for organization / team / user / repository partitions (binds 127.0.0.1; put it behind SSH or a reverse proxy)")
|
|
39
40
|
.option("--config <file>", `serve config (nuryel.serve-config/1); default ${DEFAULT_CONFIG}`)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { type ImpactMap } from "../core/testSelection.js";
|
|
3
|
+
export declare const DEFAULT_IMPACT_MAP = ".hunch-cache/test-impact.json";
|
|
4
|
+
/** Split a command template into argv tokens; double/single quotes group. */
|
|
5
|
+
export declare function tokenize(template: string): string[];
|
|
6
|
+
/** The runner argv (never a shell). A configured template wins (`{files}` expands
|
|
7
|
+
* to one argv entry per file); otherwise this repo's runner when present, else
|
|
8
|
+
* `npm test` (full) / `npm test -- <files>`. */
|
|
9
|
+
export declare function runnerArgv(root: string, template: string | undefined, files: string[], extra: string[], full: boolean): string[];
|
|
10
|
+
/** Run each test in its own runner process under NODE_V8_COVERAGE (inherited
|
|
11
|
+
* by spawned CLI/MCP children) and record the repo files each executed. An
|
|
12
|
+
* entry is `complete` only when the test file passed and produced coverage;
|
|
13
|
+
* failing tests still record their (partial) files. `only` merges into the
|
|
14
|
+
* old map, keeping each untouched entry's own commit stamp. */
|
|
15
|
+
export declare function refreshImpactMap(opts: {
|
|
16
|
+
root: string;
|
|
17
|
+
tests: string[];
|
|
18
|
+
files: string[];
|
|
19
|
+
template?: string;
|
|
20
|
+
mapPath: string;
|
|
21
|
+
only?: string[];
|
|
22
|
+
concurrency?: number;
|
|
23
|
+
log?: (line: string) => void;
|
|
24
|
+
}): Promise<{
|
|
25
|
+
map: ImpactMap;
|
|
26
|
+
failed: string[];
|
|
27
|
+
}>;
|
|
28
|
+
export declare function registerVerifyCommand(program: Command): void;
|