@davesheffer/hunch 1.39.2 → 1.39.3
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 +97 -50
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- package/dist/core/correction.js +12 -7
- package/dist/core/docanchors.js +16 -2
- package/dist/core/glob.d.ts +19 -0
- package/dist/core/glob.js +37 -0
- package/dist/core/paths.d.ts +27 -5
- package/dist/core/paths.js +51 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- package/dist/core/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +17 -1
- package/dist/core/taskReportHook.js +14 -2
- package/dist/core/workspace.d.ts +9 -0
- package/dist/core/workspace.js +15 -0
- package/dist/extractors/git.d.ts +5 -0
- package/dist/extractors/git.js +87 -10
- package/dist/extractors/indexer.d.ts +10 -0
- package/dist/extractors/indexer.js +17 -1
- package/dist/extractors/k8sManifest.js +104 -16
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +38 -7
- package/dist/integrations/hooks.js +250 -24
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +24 -21
- package/dist/integrations/workspaceLedger.d.ts +10 -0
- package/dist/integrations/workspaceLedger.js +9 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +21 -13
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/hunchStore.js +41 -11
- package/dist/store/jsonStore.d.ts +6 -4
- package/dist/store/jsonStore.js +225 -32
- package/dist/store/merge.d.ts +7 -4
- package/dist/store/merge.js +65 -5
- package/dist/store/privateMigrate.js +10 -0
- package/dist/wiki/adopt.js +13 -2
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
* doctor environment diagnostics
|
|
15
15
|
*/
|
|
16
16
|
import "./preflight.js"; // MUST stay the first import — Node-version gate before node:sqlite loads
|
|
17
|
-
import { chmodSync, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, writeFileSync, mkdirSync, mkdtempSync,
|
|
17
|
+
import { chmodSync, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, rmdirSync, symlinkSync } from "node:fs";
|
|
18
18
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
19
19
|
import { join, relative, dirname, basename, resolve, isAbsolute } from "node:path";
|
|
20
20
|
import { tmpdir } from "node:os";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { Command } from "commander";
|
|
23
|
-
import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir } from "../core/paths.js";
|
|
23
|
+
import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir, realpathNorm, repoRelativeTarget } from "../core/paths.js";
|
|
24
24
|
import { writeFileAtomic } from "../core/io.js";
|
|
25
25
|
import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
|
|
26
26
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
@@ -36,7 +36,7 @@ import { publishedStatus } from "../integrations/registry.js";
|
|
|
36
36
|
import { HunchStore } from "../store/hunchStore.js";
|
|
37
37
|
import { JsonStore } from "../store/jsonStore.js";
|
|
38
38
|
import { selectEmbedder } from "../store/embedder.js";
|
|
39
|
-
import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
|
|
39
|
+
import { assertCompleteRepoScan, indexRepo, mergeScannedEdges, scanRepo } from "../extractors/indexer.js";
|
|
40
40
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
41
41
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
42
42
|
import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
|
|
@@ -52,7 +52,7 @@ import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
|
|
|
52
52
|
import { extractInlineIntent } from "../extractors/comments.js";
|
|
53
53
|
import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
|
|
54
54
|
import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
|
|
55
|
-
import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, formatHookInstall } from "../integrations/hooks.js";
|
|
55
|
+
import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, hookInvocationLines, formatHookInstall } from "../integrations/hooks.js";
|
|
56
56
|
import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
|
|
57
57
|
import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
|
|
58
58
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
@@ -80,7 +80,7 @@ import { diffProjectDna } from "../core/projectDnaDelta.js";
|
|
|
80
80
|
import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
|
|
81
81
|
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness, workspacesConfig } from "../core/config.js";
|
|
82
82
|
import { loadOrCreateMachine, setMachineLabel, machineFile, labelLeaksIdentity } from "../core/machine.js";
|
|
83
|
-
import { worktreeRows, branchRows } from "../core/workspace.js";
|
|
83
|
+
import { worktreeRows, branchRows, publicWorkspaceRemovalRecipe } from "../core/workspace.js";
|
|
84
84
|
import { workspaceLedgerView, recordWorkspaceSnapshot, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, prunePlanFor, renderPrunePlan, applyPrune, confirmPrune, pruneConfirmQuestion } from "../integrations/workspaceLedger.js";
|
|
85
85
|
import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
|
|
86
86
|
import { isHumanConfirmed } from "../core/strictgate.js";
|
|
@@ -88,7 +88,7 @@ import { appendEvent, readEvents } from "../core/events.js";
|
|
|
88
88
|
import { computeStats, formatStats } from "../core/stats.js";
|
|
89
89
|
import { injectionMode, resetSessionInjections } from "../core/hookcache.js";
|
|
90
90
|
import { recordServed, servedSummary } from "../core/served.js";
|
|
91
|
-
import { recordTaskDelivery, reportActivity, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
|
|
91
|
+
import { recordTaskDelivery, reportActivity, reportHash, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
|
|
92
92
|
import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
|
|
93
93
|
import { renderRecalledLine } from "../core/taskReportRender.js";
|
|
94
94
|
import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
|
|
@@ -363,7 +363,7 @@ program
|
|
|
363
363
|
// any mutator. With no commit identity there is no safe public source,
|
|
364
364
|
// so leave no source-derived records behind for a future pump.
|
|
365
365
|
store.json.replaceAll("symbols", []);
|
|
366
|
-
store.json.replaceAll("edges", []);
|
|
366
|
+
store.json.replaceAll("edges", mergeScannedEdges(store.json.loadAll("edges"), []));
|
|
367
367
|
store.json.replaceAll("components", []);
|
|
368
368
|
store.reindex();
|
|
369
369
|
console.log(" ⚠ skipped code graph: no committed HEAD — commit code, then run `hunch index`");
|
|
@@ -497,8 +497,10 @@ program
|
|
|
497
497
|
// the same way; do the same for the hook so an upgrade doesn't require
|
|
498
498
|
// re-running init by hand. Gated on already having post-commit: `index`
|
|
499
499
|
// is not a setup command (it runs in CI, on any git repo), so it must
|
|
500
|
-
// never be what FIRST hooks a repo that never ran init at all.
|
|
501
|
-
|
|
500
|
+
// never be what FIRST hooks a repo that never ran init at all. A STALE
|
|
501
|
+
// post-commit block counts too (issue #315): it proves init ran, so the
|
|
502
|
+
// missing post-merge/post-checkout hooks are still this repo's to heal.
|
|
503
|
+
if (isGitRepo(root) && ["installed", "stale"].includes(hookReport(root).postCommit.state)) {
|
|
502
504
|
const inv = resolveInvocation().shell;
|
|
503
505
|
installPostMergeHook(root, inv);
|
|
504
506
|
installPostCheckoutHook(root, inv); // same upgrade path for the workspace-ledger hook
|
|
@@ -1517,8 +1519,15 @@ workspacesCmd
|
|
|
1517
1519
|
return console.log(" · or set .hunch/config.json {\"workspaces\":{\"publish_public\":true}} to commit it into this repo's .hunch/");
|
|
1518
1520
|
case "unchanged":
|
|
1519
1521
|
return console.log(`✓ unchanged since ${out.previous.observed_at} (${out.record.id}) — nothing written`);
|
|
1522
|
+
case "deferred":
|
|
1523
|
+
return console.log(out.reason === "detached-head"
|
|
1524
|
+
? "deferred: HEAD is detached — the next checkout or ledger read records this machine"
|
|
1525
|
+
: "deferred: a git operation is in progress — the next checkout or ledger read records this machine");
|
|
1520
1526
|
case "collision":
|
|
1521
|
-
|
|
1527
|
+
// The capture is routed to the overlay, so the only home it can collide with is
|
|
1528
|
+
// this repo's committed `.hunch/`. `forget` refuses that record (an additive pump
|
|
1529
|
+
// never stages a deletion), so the recipe here is the manual git removal.
|
|
1530
|
+
return fail(`not written: ${out.reason}\n the stale copy lives in this repo's .hunch/ — remove it by hand, then snapshot again:\n${publicWorkspaceRemovalRecipe(out.record.id, out.record.machine.label)}`);
|
|
1522
1531
|
case "written":
|
|
1523
1532
|
return console.log(`✓ recorded ${out.record.worktrees.length} worktree(s), ${out.record.branches.length} branch(es) as ${out.record.machine.label} (${out.record.id}, publish=${out.record.publish}) → ${out.home === "private" ? "overlay" : "public .hunch/"}${out.flushed ? `, ${out.flushed}` : ""}`);
|
|
1524
1533
|
}
|
|
@@ -1577,7 +1586,7 @@ workspacesCmd
|
|
|
1577
1586
|
console.log(` ${r.outcome === "deleted" ? "✓" : r.outcome === "skipped" ? "–" : "✗"} ${r.step.branch}: ${r.detail}`);
|
|
1578
1587
|
const failed = results.filter((r) => r.outcome === "failed").length;
|
|
1579
1588
|
const skipped = results.filter((r) => r.outcome === "skipped").length;
|
|
1580
|
-
const ledger = recorded === "written" ? " · ledger updated" : recorded === "unchanged" || recorded === "no-home" || recorded === "off" ? "" : ` · ledger not updated (${recorded})`;
|
|
1589
|
+
const ledger = recorded === "written" ? " · ledger updated" : recorded === "unchanged" || recorded === "no-home" || recorded === "off" ? "" : recorded === "deferred" ? " · ledger deferred (git operation in progress or detached HEAD)" : ` · ledger not updated (${recorded})`;
|
|
1581
1590
|
console.log(`\n${results.length - failed - skipped} deleted, ${skipped ? `${skipped} skipped (nothing changed), ` : ""}${failed} refused by git${ledger}`);
|
|
1582
1591
|
if (failed || skipped)
|
|
1583
1592
|
process.exitCode = 1;
|
|
@@ -1605,12 +1614,25 @@ workspacesCmd
|
|
|
1605
1614
|
const victims = store.recs("workspaces").filter((r) => r.machine.label === machine || r.machine.id === machine || r.id === machine);
|
|
1606
1615
|
if (!victims.length)
|
|
1607
1616
|
return fail(`no workspace record for "${machine}" — \`hunch workspaces\` lists the machines in memory`);
|
|
1608
|
-
// Decide each record's home BEFORE deleting
|
|
1609
|
-
|
|
1610
|
-
|
|
1617
|
+
// Decide each record's home BEFORE deleting anything, then flush exactly those homes.
|
|
1618
|
+
// A PUBLIC record (workspaces.publish_public) is refused rather than deleted: the
|
|
1619
|
+
// memory pump never stages a tracked deletion, so removing the file here would strand
|
|
1620
|
+
// `D .hunch/workspaces/<id>.json` and wedge every later auto-commit — see
|
|
1621
|
+
// publicWorkspaceRemovalRecipe.
|
|
1622
|
+
const overlay = victims.filter((v) => store.getPrivateRec("workspaces", v.id));
|
|
1623
|
+
const refused = victims.filter((v) => !store.getPrivateRec("workspaces", v.id));
|
|
1624
|
+
for (const v of overlay)
|
|
1611
1625
|
store.deleteWhereItLives("workspaces", v.id);
|
|
1612
|
-
|
|
1613
|
-
|
|
1626
|
+
if (overlay.length) {
|
|
1627
|
+
pumpMemoryHome(store, root, "private", `hunch: forget workspace ${machine}`);
|
|
1628
|
+
console.log(`✓ forgot ${overlay.length} record(s) for ${machine}`);
|
|
1629
|
+
}
|
|
1630
|
+
for (const v of refused) {
|
|
1631
|
+
console.log(`refused: ${v.id} (${v.machine.label}) lives in this repo's .hunch/ — Hunch publication is additive and never stages a deletion, so it is removed by hand:\n`
|
|
1632
|
+
+ publicWorkspaceRemovalRecipe(v.id, v.machine.label));
|
|
1633
|
+
}
|
|
1634
|
+
if (refused.length && !overlay.length)
|
|
1635
|
+
process.exitCode = 1;
|
|
1614
1636
|
}
|
|
1615
1637
|
finally {
|
|
1616
1638
|
store.close();
|
|
@@ -4502,18 +4524,26 @@ program
|
|
|
4502
4524
|
.requiredOption("--by <new>", "decision id that supersedes it")
|
|
4503
4525
|
.action((oldId, opts) => {
|
|
4504
4526
|
const { store, root } = storeFor();
|
|
4505
|
-
|
|
4527
|
+
// `old`'s home decides which store the close is written to; `--by` must resolve in
|
|
4528
|
+
// that SAME store, or a private `by` linked against a public `old` would write the
|
|
4529
|
+
// private id straight into the committed public store.
|
|
4530
|
+
const home = decisionMemoryHome(store, oldId);
|
|
4531
|
+
if (!store.decisionInStore(oldId, home === "private")) {
|
|
4532
|
+
store.close();
|
|
4533
|
+
return fail(`decision "${oldId}" not found`);
|
|
4534
|
+
}
|
|
4535
|
+
const by = store.decisionInStore(opts.by, home === "private");
|
|
4506
4536
|
if (!by) {
|
|
4507
4537
|
store.close();
|
|
4508
|
-
return fail(`--by decision "${opts.by}" not found`);
|
|
4538
|
+
return fail(`--by decision "${opts.by}" not found in the ${home} store that holds "${oldId}"`);
|
|
4509
4539
|
}
|
|
4510
|
-
const closed = store.supersede(oldId, by);
|
|
4540
|
+
const closed = home === "private" ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
|
|
4511
4541
|
if (!closed) {
|
|
4512
4542
|
store.close();
|
|
4513
|
-
return fail(`decision "${oldId}"
|
|
4543
|
+
return fail(`decision "${oldId}" cannot supersede itself`);
|
|
4514
4544
|
}
|
|
4515
4545
|
store.reindex();
|
|
4516
|
-
pumpMemoryHome(store, root,
|
|
4546
|
+
pumpMemoryHome(store, root, home, `hunch: supersede ${oldId} by ${opts.by}`);
|
|
4517
4547
|
console.log(`✓ ${oldId} superseded by ${opts.by} — window closed at ${closed.valid_to?.slice(0, 10)}.`);
|
|
4518
4548
|
store.close();
|
|
4519
4549
|
});
|
|
@@ -4757,10 +4787,12 @@ program
|
|
|
4757
4787
|
if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
|
|
4758
4788
|
// A Codex apply_patch touches every file it lists (and each Move-to
|
|
4759
4789
|
// destination); the Stop gate must see all of them, not only the first.
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4790
|
+
// Same in-repo filter as the pre-edit path: a scratch file outside the
|
|
4791
|
+
// repository is not a product edit, and recording it would block Stop on
|
|
4792
|
+
// a path no check can ever verify.
|
|
4793
|
+
const edited = editTargets(root, evt.tool_input);
|
|
4794
|
+
for (const e of edited)
|
|
4795
|
+
st = onEdit(st, e.target);
|
|
4764
4796
|
if (edited.length)
|
|
4765
4797
|
activity = { kind: "edit" };
|
|
4766
4798
|
}
|
|
@@ -5218,7 +5250,11 @@ program
|
|
|
5218
5250
|
const reportTaskId = hookReportTaskId(root, provider, evt);
|
|
5219
5251
|
// A new authoritative prompt gets its own full delivery. An earlier
|
|
5220
5252
|
// prompt's session-level delta cannot establish this task's receipt.
|
|
5221
|
-
|
|
5253
|
+
// A subagent reports to the prompt's task but starts with FRESH context:
|
|
5254
|
+
// it never saw that grounding, so its dedup is scoped by its own agent
|
|
5255
|
+
// identity (hashed — the raw agent_id is never retained in the key).
|
|
5256
|
+
const agentKey = evt.agent_id ? `:${reportHash(evt.agent_id).slice(7, 19)}` : "";
|
|
5257
|
+
if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}${agentKey}`, text) === "delta") {
|
|
5222
5258
|
receipts("refreshed");
|
|
5223
5259
|
emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${envelope.delivered.filter((item) => item.kind === "decisions").length} decision(s), ${envelope.delivered.filter((item) => item.kind === "constraints").length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
|
|
5224
5260
|
return;
|
|
@@ -7080,9 +7116,23 @@ program
|
|
|
7080
7116
|
// hook manager's regenerated file — issue #311) is NOT installed.
|
|
7081
7117
|
const unreachable = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
|
|
7082
7118
|
.filter(([, e]) => e.state === "unreachable");
|
|
7119
|
+
// Present where git runs it, but its command is not a Hunch launcher that
|
|
7120
|
+
// exists (hand-edited, or an absolute path gone after a reinstall) — issue #315.
|
|
7121
|
+
const stale = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
|
|
7122
|
+
.filter(([, e]) => e.state === "stale");
|
|
7123
|
+
// A bare `hunch init` regenerates post-commit without --private/--commit
|
|
7124
|
+
// and pre-commit without --strict, so pointing at it without naming the
|
|
7125
|
+
// options that keep them silently DOWNGRADES the install. --commit is left
|
|
7126
|
+
// out: init's auto-commit is ON unless --no-auto-commit is passed, so a
|
|
7127
|
+
// bare re-run keeps it and naming it here would claim a loss that never happens.
|
|
7128
|
+
const carried = [...new Set(stale.flatMap(([, e]) => e.flags ?? []))].filter((f) => f !== "--commit");
|
|
7129
|
+
const keep = carried.map((f) => (f === "--strict" ? "--enforce-strict" : f === "--private" ? "--private-sync (or --shared-sync)" : "")).filter(Boolean);
|
|
7130
|
+
const staleKeep = carried.length
|
|
7131
|
+
? ` — the stale block carried ${carried.join(", ")}${keep.length ? `: pass ${keep.join(", ")} or they are dropped` : ""}`
|
|
7132
|
+
: "";
|
|
7083
7133
|
// `hunch index` only ever installs post-merge onto an existing
|
|
7084
|
-
// post-commit install (it never hooks an un-hooked repo — see
|
|
7085
|
-
//
|
|
7134
|
+
// post-commit install (it never hooks an un-hooked repo — see the
|
|
7135
|
+
// hookReport(root).postCommit.state gate above) and never writes
|
|
7086
7136
|
// into a manager-owned hook — so the fix hint must not point there when
|
|
7087
7137
|
// post-commit itself is missing or a hook manager is in play.
|
|
7088
7138
|
const managedMissing = [report.postCommit, report.postMerge].find((e) => e.state === "missing" && e.manager !== "none");
|
|
@@ -7092,6 +7142,7 @@ program
|
|
|
7092
7142
|
const problems = [
|
|
7093
7143
|
missing.length ? `⚠ missing ${missing.join(", ")} — ${fix}` : "",
|
|
7094
7144
|
unreachable.length ? `⚠ installed but unreachable: ${unreachable.map(([n]) => n).join(", ")} — never runs; run \`hunch init\` to print the snippet for your hook manager` : "",
|
|
7145
|
+
stale.length ? `⚠ stale: ${stale.map(([n]) => n).join(", ")} — the hook's command is not a working Hunch launcher; run \`hunch init\` to regenerate it${staleKeep}` : "",
|
|
7095
7146
|
].filter(Boolean);
|
|
7096
7147
|
console.log(`hooks: ${problems.length
|
|
7097
7148
|
? problems.join("\n ")
|
|
@@ -7099,6 +7150,13 @@ program
|
|
|
7099
7150
|
for (const [name, e] of unreachable) {
|
|
7100
7151
|
console.log(dim(` ↳ ${name}: ${e.reason ?? "the block sits where git never reaches it"} (${rel(root, e.path)})`));
|
|
7101
7152
|
}
|
|
7153
|
+
for (const [name, e] of stale) {
|
|
7154
|
+
console.log(dim(` ↳ ${name}: ${e.reason ?? "the block's command is not a Hunch launcher"} (${rel(root, e.path)})`));
|
|
7155
|
+
}
|
|
7156
|
+
// Where the live hooks point — informational (a hook may legitimately run
|
|
7157
|
+
// another install), but the fastest way to spot a stale/foreign launcher.
|
|
7158
|
+
for (const line of hookInvocationLines(report, resolveInvocation().shell))
|
|
7159
|
+
console.log(dim(` ↳ ${line}`));
|
|
7102
7160
|
// Workspace ledger: what this machine is called, whether its record is in memory,
|
|
7103
7161
|
// and whether the checkout hook that keeps it fresh is installed.
|
|
7104
7162
|
try {
|
|
@@ -7107,7 +7165,7 @@ program
|
|
|
7107
7165
|
const others = store.recs("workspaces").filter((r) => r.machine.id !== machine.id).length;
|
|
7108
7166
|
const leak = labelLeaksIdentity(machine.label);
|
|
7109
7167
|
console.log(`workspaces: this machine is ${machine.label}${leak ? ` (⚠ label equals the ${leak})` : ""} · record in memory: ${stored ? `yes (${stored.observed_at})` : "no"} · ${others} other machine(s)` +
|
|
7110
|
-
`${hooks.postCheckout ? " · post-checkout hook installed" : report.postCheckout.state === "unreachable" ? " · ⚠ post-checkout hook unreachable" : hooks.postCommit ? " · post-checkout hook not installed (`hunch index` adds it)" : ""}`);
|
|
7168
|
+
`${hooks.postCheckout ? " · post-checkout hook installed" : report.postCheckout.state === "unreachable" ? " · ⚠ post-checkout hook unreachable" : report.postCheckout.state === "stale" ? " · ⚠ post-checkout hook stale" : hooks.postCommit ? " · post-checkout hook not installed (`hunch index` adds it)" : ""}`);
|
|
7111
7169
|
}
|
|
7112
7170
|
catch { /* no machine file writable: nothing to report */ }
|
|
7113
7171
|
}
|
|
@@ -7252,28 +7310,17 @@ function readStdin() {
|
|
|
7252
7310
|
});
|
|
7253
7311
|
}
|
|
7254
7312
|
/** Absolute edit path → repo-relative, forward-slash (constraint scopes are
|
|
7255
|
-
* forward-slash globs even on Windows)
|
|
7256
|
-
|
|
7257
|
-
*
|
|
7258
|
-
*
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
catch {
|
|
7264
|
-
const parent = dirname(p);
|
|
7265
|
-
if (parent === p)
|
|
7266
|
-
return p; // hit the root; nothing more to resolve
|
|
7267
|
-
return join(realpathNorm(parent), basename(p));
|
|
7268
|
-
}
|
|
7269
|
-
}
|
|
7270
|
-
/** Repo-relative POSIX path. BOTH ends are realpath-normalized first: on macOS
|
|
7271
|
-
* `process.cwd()` (hence findRoot) resolves /var→/private/var, but a hook event's
|
|
7272
|
-
* file_path arrives UN-resolved — so a naive relative() yields a bogus "../" path
|
|
7273
|
-
* under any symlinked root (/var, /tmp, symlinked $HOME) and the caller treats the
|
|
7274
|
-
* file as outside the repo, silently dropping all context (dec_e0a36efbf5). */
|
|
7313
|
+
* forward-slash globs even on Windows), or "" when `abs` isn't (resolvably)
|
|
7314
|
+
* inside `root`. Thin adapter over the shared `repoRelativeTarget` (core/paths.ts,
|
|
7315
|
+
* which realpath-normalizes both ends first — see its docstring for why, incl.
|
|
7316
|
+
* dec_e0a36efbf5): that function passes an unresolvable absolute path through
|
|
7317
|
+
* UNCHANGED (still absolute) rather than signaling failure directly, since other
|
|
7318
|
+
* callers want the original target back to fail their own match safely. This
|
|
7319
|
+
* adapter converts that "still absolute" signal to "" — the sentinel `onEdit`
|
|
7320
|
+
* and `editTargets` below already treat as "skip, not in this repo". */
|
|
7275
7321
|
function toRepoRel(root, abs) {
|
|
7276
|
-
|
|
7322
|
+
const rel = repoRelativeTarget(abs, root);
|
|
7323
|
+
return isAbsolute(rel) || /^[a-zA-Z]:/.test(rel) ? "" : rel;
|
|
7277
7324
|
}
|
|
7278
7325
|
/** The in-repo files a pre-edit event would change, each with the lines the edit
|
|
7279
7326
|
* ADDS to that file. A Codex apply_patch yields one entry per touched path (a
|
package/dist/cli/serve.d.ts
CHANGED
|
@@ -1,2 +1,16 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
|
+
/** Compact one served partition's ledger. Compaction is read → slice → write, so it takes the
|
|
3
|
+
* partition write lock like every other writer (#286): without it a live server's append between
|
|
4
|
+
* the read and the write is overwritten, regressing head_seq and reusing a seq. The ledger lives
|
|
5
|
+
* in the store's RESOLVED state home (the overlay in shared mode), never `<root>/.hunch` blindly. */
|
|
6
|
+
export declare function compactPartition(root: string, scope: {
|
|
7
|
+
kind: "organization" | "team" | "user" | "repository";
|
|
8
|
+
id: string;
|
|
9
|
+
}, keep: number, lockOptions?: {
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
}): Promise<{
|
|
12
|
+
dropped: number;
|
|
13
|
+
floor_seq: number;
|
|
14
|
+
head_seq: number;
|
|
15
|
+
}>;
|
|
2
16
|
export declare function registerServeCommands(program: Command): void;
|
package/dist/cli/serve.js
CHANGED
|
@@ -6,9 +6,9 @@ import { initServeConfig, partitionFor, readServeConfig, writeServeConfig } from
|
|
|
6
6
|
import { compactLedger } from "../store/changeLedger.js";
|
|
7
7
|
import { HunchStore } from "../store/hunchStore.js";
|
|
8
8
|
import { hunchPaths } from "../core/paths.js";
|
|
9
|
-
import { partitionOf } from "../store/stateBinding.js";
|
|
9
|
+
import { partitionOf, stateHomeFor } from "../store/stateBinding.js";
|
|
10
10
|
import { formatReplayReport, verifyReplay } from "../store/replay.js";
|
|
11
|
-
import {
|
|
11
|
+
import { withWriteLock } from "../serve/writelock.js";
|
|
12
12
|
import { ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
13
13
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
14
14
|
function parseScopeArg(value) {
|
|
@@ -18,6 +18,20 @@ function parseScopeArg(value) {
|
|
|
18
18
|
throw new Error(`partition must be kind:id (organization|team|user|repository), got "${value}"`);
|
|
19
19
|
return parsed.data;
|
|
20
20
|
}
|
|
21
|
+
/** Compact one served partition's ledger. Compaction is read → slice → write, so it takes the
|
|
22
|
+
* partition write lock like every other writer (#286): without it a live server's append between
|
|
23
|
+
* the read and the write is overwritten, regressing head_seq and reusing a seq. The ledger lives
|
|
24
|
+
* in the store's RESOLVED state home (the overlay in shared mode), never `<root>/.hunch` blindly. */
|
|
25
|
+
export async function compactPartition(root, scope, keep, lockOptions = {}) {
|
|
26
|
+
const store = new HunchStore(hunchPaths(root));
|
|
27
|
+
try {
|
|
28
|
+
const { hunchDir } = stateHomeFor(store, scope);
|
|
29
|
+
return await withWriteLock(hunchDir, () => compactLedger(hunchDir, scope, { keep }), lockOptions);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
store.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
21
35
|
export function registerServeCommands(program) {
|
|
22
36
|
const DEFAULT_CONFIG = "hunch-serve.json";
|
|
23
37
|
const serve = program.command("serve")
|
|
@@ -57,7 +71,7 @@ export function registerServeCommands(program) {
|
|
|
57
71
|
.requiredOption("--partition <kind:id>", "the partition whose ledger to compact")
|
|
58
72
|
.option("--keep <n>", "events to keep", "1000")
|
|
59
73
|
.option("--json", "machine-readable output")
|
|
60
|
-
.action((opts) => {
|
|
74
|
+
.action(async (opts) => {
|
|
61
75
|
const parent = serve.opts();
|
|
62
76
|
const config = readServeConfig(resolve(parent.config ?? DEFAULT_CONFIG));
|
|
63
77
|
const scope = parseScopeArg(opts.partition);
|
|
@@ -67,7 +81,7 @@ export function registerServeCommands(program) {
|
|
|
67
81
|
const keep = Number(opts.keep);
|
|
68
82
|
if (!Number.isInteger(keep) || keep < 0)
|
|
69
83
|
throw new Error("--keep must be a non-negative integer");
|
|
70
|
-
const result =
|
|
84
|
+
const result = await compactPartition(partition.root, scope, keep);
|
|
71
85
|
if (opts.json) {
|
|
72
86
|
console.log(JSON.stringify({ partition: scopePath(scope), ...result }));
|
|
73
87
|
return;
|
package/dist/core/correction.js
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* - buildCorrectionConstraint(): mint the Constraint record (human-confirmed,
|
|
9
9
|
* scoped conservatively) that the pre-edit hook + CI guard then enforce.
|
|
10
10
|
*/
|
|
11
|
-
import { isAbsolute
|
|
11
|
+
import { isAbsolute } from "node:path";
|
|
12
12
|
import { constraintId } from "./ids.js";
|
|
13
|
-
import { toPosixTarget } from "./paths.js";
|
|
13
|
+
import { toPosixTarget, repoRelativeTarget } from "./paths.js";
|
|
14
14
|
import { deriveForbids } from "./constraintmatch.js";
|
|
15
15
|
/** Correction cues. Deliberately conservative — anchored to imperative/rebuke
|
|
16
16
|
* phrasing, not bare "no", so ordinary conversational negation ("no idea",
|
|
@@ -57,7 +57,13 @@ export const TESTIMONY_CORRECTION_RATIONALE = "Recorded by the agent as a correc
|
|
|
57
57
|
* Returns "" when the hint cannot be made repo-relative (no root, or a path outside the
|
|
58
58
|
* repo). The caller then falls back to "**", where the existing severity guard
|
|
59
59
|
* down-ranks a non-explicit blocking rule to a warning — fail-safe and honest, rather
|
|
60
|
-
* than a blocking rule enforced nowhere.
|
|
60
|
+
* than a blocking rule enforced nowhere.
|
|
61
|
+
*
|
|
62
|
+
* Thin adapter over the shared `repoRelativeTarget` (core/paths.ts): that function
|
|
63
|
+
* passes an unresolvable absolute hint through UNCHANGED (still absolute) rather
|
|
64
|
+
* than signaling failure directly, since other callers (checkConstraints, why())
|
|
65
|
+
* want the original target back to fail their own match safely. This adapter
|
|
66
|
+
* converts that "still absolute" signal to "" — the fail-safe this caller needs. */
|
|
61
67
|
function repoRelativeHint(rawHint, root) {
|
|
62
68
|
if (!rawHint)
|
|
63
69
|
return "";
|
|
@@ -66,10 +72,9 @@ function repoRelativeHint(rawHint, root) {
|
|
|
66
72
|
return rawHint;
|
|
67
73
|
if (!root)
|
|
68
74
|
return "";
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return rel;
|
|
75
|
+
const rewritten = repoRelativeTarget(rawHint, root);
|
|
76
|
+
const stillAbsolute = isAbsolute(rewritten) || /^[a-zA-Z]:/.test(rewritten);
|
|
77
|
+
return stillAbsolute ? "" : rewritten;
|
|
73
78
|
}
|
|
74
79
|
/**
|
|
75
80
|
* Build the Constraint a correction mints. Pure (caller passes `now`), so the
|
package/dist/core/docanchors.js
CHANGED
|
@@ -4,7 +4,9 @@ const MARKER = /<!--\s*hunch:topic\s+([A-Za-z0-9._/-]+)(?:\s+(dec_[A-Za-z0-9]+))
|
|
|
4
4
|
* documentation EXAMPLE of a marker never registers as a live anchor.
|
|
5
5
|
* CommonMark-lite: a fence of N chars (≤3 leading spaces) closes only on a
|
|
6
6
|
* line of ≥N of the same char and nothing else; an unclosed fence runs to
|
|
7
|
-
* EOF; a backtick fence's info string may not itself contain a backtick.
|
|
7
|
+
* EOF; a backtick fence's info string may not itself contain a backtick.
|
|
8
|
+
* Expects LF-normalized text — see parseDocAnchors's normalization; a
|
|
9
|
+
* caller that skips it re-opens the CRLF fence-detection bug. */
|
|
8
10
|
function fencedRanges(text) {
|
|
9
11
|
const ranges = [];
|
|
10
12
|
let open = null;
|
|
@@ -31,7 +33,11 @@ function fencedRanges(text) {
|
|
|
31
33
|
/** Character ranges covered by inline code spans (`…`), same rationale as
|
|
32
34
|
* fencedRanges: prose quoting a marker in backticks is showing an example.
|
|
33
35
|
* CommonMark-lite: an opener run pairs with the next run of the SAME length
|
|
34
|
-
* on the same line; unpaired runs never open a span.
|
|
36
|
+
* on the same line; unpaired runs never open a span.
|
|
37
|
+
* Expects LF-normalized text — see parseDocAnchors's normalization.
|
|
38
|
+
* `split("\n")` does not split a bare CR, so on CR-only input the whole
|
|
39
|
+
* document reads as one line and stray backticks on different lines
|
|
40
|
+
* falsely pair. */
|
|
35
41
|
function inlineSpanRanges(text) {
|
|
36
42
|
const ranges = [];
|
|
37
43
|
let offset = 0;
|
|
@@ -55,6 +61,14 @@ function inlineSpanRanges(text) {
|
|
|
55
61
|
* fenced code blocks or inline code spans are examples, not declarations,
|
|
56
62
|
* and are skipped. */
|
|
57
63
|
export function parseDocAnchors(text) {
|
|
64
|
+
// fencedRanges is CRLF-sensitive (its fence-line regex's `.` excludes \r, so
|
|
65
|
+
// "```\r" never matched at all on a CRLF checkout); inlineSpanRanges is
|
|
66
|
+
// lone-CR-sensitive (split("\n") doesn't split a bare CR — see its
|
|
67
|
+
// docblock). Either way an example marker inside a fence/span registered as
|
|
68
|
+
// a live, pinned anchor. Normalizing both CRLF and lone CR once here keeps
|
|
69
|
+
// fencedRanges/inlineSpanRanges/MARKER offsets consistent with each other
|
|
70
|
+
// and with the line numbers reported below.
|
|
71
|
+
text = text.replace(/\r\n?/g, "\n");
|
|
58
72
|
const out = [];
|
|
59
73
|
const skip = [...fencedRanges(text), ...inlineSpanRanges(text)];
|
|
60
74
|
MARKER.lastIndex = 0;
|
package/dist/core/glob.d.ts
CHANGED
|
@@ -7,3 +7,22 @@ export declare function pathMatchesGlob(path: string, glob: string): boolean;
|
|
|
7
7
|
/** Do two concrete repo paths identify the same path at different prefix depth?
|
|
8
8
|
* Segment-anchored: `x/scenario.ts` relates to `scenario.ts`, never `io.ts`. */
|
|
9
9
|
export declare function pathsRelated(left: string, right: string): boolean;
|
|
10
|
+
/** True when `target` is a path Hunch's index already knows about: an indexed
|
|
11
|
+
* symbol's exact file, or a path an indexed component's `paths` glob(s) cover.
|
|
12
|
+
* The latter catches a symbol-less-but-indexed file (README.md, package.json,
|
|
13
|
+
* Dockerfile, ...) that has zero tree-sitter symbols but is still a real,
|
|
14
|
+
* known file. Derived entirely from already-loaded graph data — never the
|
|
15
|
+
* filesystem — so the answer doesn't depend on untracked working-tree state
|
|
16
|
+
* (a deleted-but-still-indexed path stays "real"; issue #299). */
|
|
17
|
+
export declare function isIndexedPath(target: string, symbolFiles: Iterable<string>, componentPaths: Iterable<readonly string[]>): boolean;
|
|
18
|
+
/** Resolve symbols matching `target`, tiered: exact id > exact name > exact file >
|
|
19
|
+
* (only when `target` is NOT a path already known to the index) segment-anchored
|
|
20
|
+
* suffix. A real indexed file with zero symbols must return [] rather than fall
|
|
21
|
+
* through to the suffix tier, which would leak an unrelated same-basename file's
|
|
22
|
+
* records (issue #299) — callers compute `indexed` via `isIndexedPath` first so
|
|
23
|
+
* the "is this a real path" question is answered identically everywhere. */
|
|
24
|
+
export declare function matchSymbolsTiered<S extends {
|
|
25
|
+
id: string;
|
|
26
|
+
file: string;
|
|
27
|
+
name: string;
|
|
28
|
+
}>(target: string, symbols: readonly S[], indexed: boolean): S[];
|
package/dist/core/glob.js
CHANGED
|
@@ -66,4 +66,41 @@ export function pathsRelated(left, right) {
|
|
|
66
66
|
const b = norm(right);
|
|
67
67
|
return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`);
|
|
68
68
|
}
|
|
69
|
+
/** True when `target` is a path Hunch's index already knows about: an indexed
|
|
70
|
+
* symbol's exact file, or a path an indexed component's `paths` glob(s) cover.
|
|
71
|
+
* The latter catches a symbol-less-but-indexed file (README.md, package.json,
|
|
72
|
+
* Dockerfile, ...) that has zero tree-sitter symbols but is still a real,
|
|
73
|
+
* known file. Derived entirely from already-loaded graph data — never the
|
|
74
|
+
* filesystem — so the answer doesn't depend on untracked working-tree state
|
|
75
|
+
* (a deleted-but-still-indexed path stays "real"; issue #299). */
|
|
76
|
+
export function isIndexedPath(target, symbolFiles, componentPaths) {
|
|
77
|
+
for (const f of symbolFiles)
|
|
78
|
+
if (f === target)
|
|
79
|
+
return true;
|
|
80
|
+
for (const globs of componentPaths)
|
|
81
|
+
for (const g of globs)
|
|
82
|
+
if (pathMatchesGlob(target, g))
|
|
83
|
+
return true;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
/** Resolve symbols matching `target`, tiered: exact id > exact name > exact file >
|
|
87
|
+
* (only when `target` is NOT a path already known to the index) segment-anchored
|
|
88
|
+
* suffix. A real indexed file with zero symbols must return [] rather than fall
|
|
89
|
+
* through to the suffix tier, which would leak an unrelated same-basename file's
|
|
90
|
+
* records (issue #299) — callers compute `indexed` via `isIndexedPath` first so
|
|
91
|
+
* the "is this a real path" question is answered identically everywhere. */
|
|
92
|
+
export function matchSymbolsTiered(target, symbols, indexed) {
|
|
93
|
+
const byId = symbols.find((s) => s.id === target);
|
|
94
|
+
if (byId)
|
|
95
|
+
return [byId];
|
|
96
|
+
const byName = symbols.filter((s) => s.name === target);
|
|
97
|
+
if (byName.length)
|
|
98
|
+
return byName;
|
|
99
|
+
const exact = symbols.filter((s) => s.file === target);
|
|
100
|
+
if (exact.length)
|
|
101
|
+
return exact;
|
|
102
|
+
if (indexed)
|
|
103
|
+
return [];
|
|
104
|
+
return symbols.filter((s) => pathsRelated(s.file, target));
|
|
105
|
+
}
|
|
69
106
|
//# sourceMappingURL=glob.js.map
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
export declare const HUNCH_DIR = ".hunch";
|
|
2
|
-
/** Canonicalize a free-form path/target to
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
/** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
|
|
3
|
+
* path with "/" — git emits it on all OSes — so any user- or agent-supplied
|
|
4
|
+
* target must be normalized before comparison, otherwise a Windows caller passing
|
|
5
|
+
* `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
|
|
6
|
+
* symbol names too: they contain no backslashes. This does NOT make a path
|
|
7
|
+
* repo-relative — an absolute path passes through with its separators flipped,
|
|
8
|
+
* unchanged otherwise; use `repoRelativeTarget` for that. */
|
|
7
9
|
export declare function toPosixTarget(target: string): string;
|
|
10
|
+
/** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
|
|
11
|
+
* to Write, or a glob whose concrete segments aren't literal): resolve the longest
|
|
12
|
+
* existing ancestor, then re-append the missing tail. Idempotent on an
|
|
13
|
+
* already-resolved path. */
|
|
14
|
+
export declare function realpathNorm(p: string): string;
|
|
15
|
+
/** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
|
|
16
|
+
* `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
|
|
17
|
+
* repo-relative, so a caller that hands over an absolute edit-payload path
|
|
18
|
+
* matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
|
|
19
|
+
* returned empty for an absolute target that plainly had a matching rule). A
|
|
20
|
+
* relative target, a glob, or an absolute path outside `root` passes through
|
|
21
|
+
* unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
|
|
22
|
+
*
|
|
23
|
+
* BOTH ends are realpath-normalized before the relative-path computation: on
|
|
24
|
+
* macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
|
|
25
|
+
* hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
|
|
26
|
+
* bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
|
|
27
|
+
* caller treats the file as outside the repo, silently dropping all context
|
|
28
|
+
* (dec_e0a36efbf5). */
|
|
29
|
+
export declare function repoRelativeTarget(target: string, root: string): string;
|
|
8
30
|
export interface HunchPaths {
|
|
9
31
|
/** Repo root (where .hunch/ lives). */
|
|
10
32
|
root: string;
|
package/dist/core/paths.js
CHANGED
|
@@ -1,16 +1,61 @@
|
|
|
1
1
|
/** Filesystem layout for the Hunch (DESIGN.md §6 folder structure). */
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
4
|
-
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
5
|
export const HUNCH_DIR = ".hunch";
|
|
6
|
-
/** Canonicalize a free-form path/target to
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
/** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
|
|
7
|
+
* path with "/" — git emits it on all OSes — so any user- or agent-supplied
|
|
8
|
+
* target must be normalized before comparison, otherwise a Windows caller passing
|
|
9
|
+
* `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
|
|
10
|
+
* symbol names too: they contain no backslashes. This does NOT make a path
|
|
11
|
+
* repo-relative — an absolute path passes through with its separators flipped,
|
|
12
|
+
* unchanged otherwise; use `repoRelativeTarget` for that. */
|
|
11
13
|
export function toPosixTarget(target) {
|
|
12
14
|
return target.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
13
15
|
}
|
|
16
|
+
/** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
|
|
17
|
+
* to Write, or a glob whose concrete segments aren't literal): resolve the longest
|
|
18
|
+
* existing ancestor, then re-append the missing tail. Idempotent on an
|
|
19
|
+
* already-resolved path. */
|
|
20
|
+
export function realpathNorm(p) {
|
|
21
|
+
try {
|
|
22
|
+
return realpathSync.native(p);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
const parent = dirname(p);
|
|
26
|
+
if (parent === p)
|
|
27
|
+
return p; // hit the root; nothing more to resolve
|
|
28
|
+
return join(realpathNorm(parent), basename(p));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
|
|
32
|
+
* `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
|
|
33
|
+
* repo-relative, so a caller that hands over an absolute edit-payload path
|
|
34
|
+
* matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
|
|
35
|
+
* returned empty for an absolute target that plainly had a matching rule). A
|
|
36
|
+
* relative target, a glob, or an absolute path outside `root` passes through
|
|
37
|
+
* unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
|
|
38
|
+
*
|
|
39
|
+
* BOTH ends are realpath-normalized before the relative-path computation: on
|
|
40
|
+
* macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
|
|
41
|
+
* hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
|
|
42
|
+
* bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
|
|
43
|
+
* caller treats the file as outside the repo, silently dropping all context
|
|
44
|
+
* (dec_e0a36efbf5). */
|
|
45
|
+
export function repoRelativeTarget(target, root) {
|
|
46
|
+
const t = toPosixTarget(target);
|
|
47
|
+
const looksAbsolute = isAbsolute(t) || /^[a-zA-Z]:/.test(t);
|
|
48
|
+
if (!looksAbsolute)
|
|
49
|
+
return t;
|
|
50
|
+
// `root` is posix'd too, not just `target`: realpathNorm's fallback walk decomposes
|
|
51
|
+
// by "/" (via node:path, which on a POSIX host does NOT treat "\" as a separator),
|
|
52
|
+
// so an un-posix'd root and a posix'd target would decompose into differently-shaped
|
|
53
|
+
// ancestor chains and never share a common realpath'd prefix.
|
|
54
|
+
const rel = toPosixTarget(relative(realpathNorm(toPosixTarget(root)), realpathNorm(t)));
|
|
55
|
+
if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
|
|
56
|
+
return t;
|
|
57
|
+
return rel;
|
|
58
|
+
}
|
|
14
59
|
export function hunchPaths(root) {
|
|
15
60
|
const hunch = join(root, HUNCH_DIR);
|
|
16
61
|
return {
|