@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.
Files changed (46) hide show
  1. package/dist/cli/index.js +97 -50
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/core/correction.js +12 -7
  5. package/dist/core/docanchors.js +16 -2
  6. package/dist/core/glob.d.ts +19 -0
  7. package/dist/core/glob.js +37 -0
  8. package/dist/core/paths.d.ts +27 -5
  9. package/dist/core/paths.js +51 -6
  10. package/dist/core/procstart.d.ts +94 -0
  11. package/dist/core/procstart.js +244 -0
  12. package/dist/core/served.d.ts +6 -3
  13. package/dist/core/served.js +10 -6
  14. package/dist/core/taskReport.d.ts +2 -0
  15. package/dist/core/taskReport.js +46 -11
  16. package/dist/core/taskReportEvidence.js +17 -1
  17. package/dist/core/taskReportHook.js +14 -2
  18. package/dist/core/workspace.d.ts +9 -0
  19. package/dist/core/workspace.js +15 -0
  20. package/dist/extractors/git.d.ts +5 -0
  21. package/dist/extractors/git.js +87 -10
  22. package/dist/extractors/indexer.d.ts +10 -0
  23. package/dist/extractors/indexer.js +17 -1
  24. package/dist/extractors/k8sManifest.js +104 -16
  25. package/dist/integrations/hookmatch.d.ts +25 -0
  26. package/dist/integrations/hookmatch.js +58 -0
  27. package/dist/integrations/hooks.d.ts +38 -7
  28. package/dist/integrations/hooks.js +250 -24
  29. package/dist/integrations/providers.js +2 -17
  30. package/dist/integrations/scaffold.d.ts +1 -1
  31. package/dist/integrations/scaffold.js +24 -21
  32. package/dist/integrations/workspaceLedger.d.ts +10 -0
  33. package/dist/integrations/workspaceLedger.js +9 -1
  34. package/dist/mcp/server.d.ts +10 -0
  35. package/dist/mcp/server.js +21 -13
  36. package/dist/serve/writelock.d.ts +21 -1
  37. package/dist/serve/writelock.js +157 -16
  38. package/dist/store/hunchStore.js +41 -11
  39. package/dist/store/jsonStore.d.ts +6 -4
  40. package/dist/store/jsonStore.js +225 -32
  41. package/dist/store/merge.d.ts +7 -4
  42. package/dist/store/merge.js +65 -5
  43. package/dist/store/privateMigrate.js +10 -0
  44. package/dist/wiki/adopt.js +13 -2
  45. package/package.json +1 -1
  46. package/server.json +2 -2
@@ -162,6 +162,11 @@ export declare function isLinkedWorktree(cwd: string): boolean;
162
162
  /** Current branch name (e.g. "main", "feat/x"), or "" in detached HEAD / non-repo.
163
163
  * Stamped onto auto-captured decisions so branch-scoped work stays filterable. */
164
164
  export declare function currentBranch(cwd: string): string;
165
+ /** Whether HEAD is somewhere a write must not land: mid rebase/merge/cherry-pick/revert/bisect
166
+ * ("git-operation-in-progress", which wins when both hold), or detached ("detached-head").
167
+ * null when HEAD is a settled branch — and also when git cannot answer at all (not a repo,
168
+ * git missing), so a caller keeps its pre-existing behavior rather than failing closed. */
169
+ export declare function gitHeadUnsettled(cwd: string): "git-operation-in-progress" | "detached-head" | null;
165
170
  /** Files changed in a single commit. `--root` makes the initial commit (which
166
171
  * has no parent) report its files as additions instead of returning nothing. */
167
172
  export declare function commitFiles(sha: string, cwd: string): string[];
@@ -4,7 +4,7 @@ import { execFileSync } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
5
  import { devNull, tmpdir } from "node:os";
6
6
  import { isAbsolute, resolve, join, basename, dirname, relative, sep } from "node:path";
7
- import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync } from "node:fs";
7
+ import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync, existsSync } from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { MEMLOG_FORMAT } from "../core/memorylog.js";
10
10
  import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
@@ -1686,14 +1686,25 @@ export function syncExistingHunch(hunchDir, protectedRepoRoot, timeoutMs = CAPTU
1686
1686
  }
1687
1687
  }
1688
1688
  const UNKNOWN_LOCK_STALE_MS = 10 * 60_000;
1689
+ // An owner-less lock directory is also a NORMAL transient state: the window
1690
+ // between the two mkdir calls in createOwnedCommitLock, the release window where
1691
+ // recursive cleanup removes owner-<pid> before the outer directory, and a lost
1692
+ // reclaim race. This bounds how long a lock may stay owner-less before it is
1693
+ // treated as stranded. At ENTRY it is compared with the directory's mtime
1694
+ // (creating the directory, or adding/removing a child entry, touches it), so an
1695
+ // old stranded lock no-ops instantly. INSIDE the wait loop it is a local
1696
+ // continuously-owner-less timer instead, so the wait depends on neither
1697
+ // filesystem mtime semantics nor clock agreement between writers.
1698
+ const OWNERLESS_LOCK_FRESH_MS = 2_000;
1689
1699
  function createOwnedCommitLock(lock) {
1690
1700
  let created = false;
1691
1701
  try {
1692
1702
  // mkdir is the exclusive atomic operation here. Renaming a staged directory
1693
1703
  // is NOT exclusive on POSIX: it may replace an already-existing empty lock
1694
1704
  // directory, which would steal a fresh legacy/ownerless lock. There is a
1695
- // harmless ownerless window between these two mkdir calls; contenders treat
1696
- // it as held until the conservative legacy TTL expires.
1705
+ // harmless ownerless window between these two mkdir calls: it reads as held,
1706
+ // and is reclaimed only after the conservative legacy TTL expires. A capture
1707
+ // waits it out (waitForCommitLockHandoff); other callers just report busy.
1697
1708
  mkdirSync(lock);
1698
1709
  created = true;
1699
1710
  // Empty directories are not Git worktree entries, so owner metadata cannot
@@ -1781,21 +1792,66 @@ function acquireCommitLock(lock) {
1781
1792
  }
1782
1793
  return createOwnedCommitLock(lock) ? { state: "acquired" } : { state: "held-unknown" };
1783
1794
  }
1795
+ /** Is an owner-less lock directory recent enough to be a transient window
1796
+ * rather than a stranded one? Only meaningful for the FIRST snapshot, where
1797
+ * there is no local history to measure against. An unreadable directory is not
1798
+ * assumed transient: only ENOENT is, because the owner may have just finished
1799
+ * releasing. `Math.abs` so a clock-skewed far-FUTURE mtime is not read as
1800
+ * recent forever. */
1801
+ function ownerlessLockLooksTransient(lock) {
1802
+ try {
1803
+ return Math.abs(Date.now() - statSync(lock).mtimeMs) <= OWNERLESS_LOCK_FRESH_MS;
1804
+ }
1805
+ catch (error) {
1806
+ return error.code === "ENOENT";
1807
+ }
1808
+ }
1784
1809
  function waitForCommitLockHandoff(lock, first, timeoutMs) {
1785
- if (first.state !== "held-live" || first.ownerPid === process.pid)
1810
+ if (first.state === "held-live" && first.ownerPid === process.pid)
1811
+ return false;
1812
+ // An owner-less FIRST snapshot is either a transient window (creation,
1813
+ // release, lost reclaim race) or a stranded/legacy lock. Only the former is
1814
+ // worth waiting for: a stranded lock must not block the capture for the whole
1815
+ // handoff. This is the one place the WAIT POLICY consults the directory's
1816
+ // mtime; acquireCommitLock still stats it for the legacy reclaim TTL.
1817
+ if (first.state === "held-unknown" && !ownerlessLockLooksTransient(lock))
1786
1818
  return false;
1787
1819
  const deadline = Date.now() + timeoutMs;
1788
1820
  const sleeper = new Int32Array(new SharedArrayBuffer(4));
1789
1821
  let attempt = first;
1822
+ // How long the lock has read as held-unknown CONTINUOUSLY, measured locally:
1823
+ // a sighting of a live owner resets it, because a genuine release window lasts
1824
+ // milliseconds. Reading it from this clock keeps the wait independent of the
1825
+ // filesystem's mtime granularity and of other writers' clocks. Besides a truly
1826
+ // owner-less directory this also covers a dead owner whose reclaim another
1827
+ // process has claimed — equally not a handoff this contender can wait for.
1828
+ let ownerlessSince = first.state === "held-unknown" ? Date.now() : null;
1829
+ // "Held" while the directory is not even there means mkdir keeps failing for a
1830
+ // reason that is not contention — a missing, read-only or full store — and no
1831
+ // amount of waiting will hand anything off. Require a short streak so one
1832
+ // sample taken mid-release is not mistaken for it.
1833
+ let vanishedStreak = 0;
1790
1834
  while (Date.now() < deadline) {
1791
1835
  if (attempt.state === "acquired")
1792
1836
  return true;
1793
- // Once the first snapshot proved a live owner, an owner-less snapshot can be
1794
- // the normal release window: recursive cleanup removes owner-<pid> before
1795
- // removing the outer lock directory. Keep the bounded handoff wait through
1796
- // that transient state instead of reporting a false busy/no-op result.
1797
- if (attempt.state === "held-live" && attempt.ownerPid === process.pid)
1798
- return false;
1837
+ if (attempt.state === "held-live") {
1838
+ if (attempt.ownerPid === process.pid)
1839
+ return false;
1840
+ ownerlessSince = null;
1841
+ vanishedStreak = 0;
1842
+ }
1843
+ else if (attempt.state === "held-unknown") {
1844
+ ownerlessSince ??= Date.now();
1845
+ if (Date.now() - ownerlessSince > OWNERLESS_LOCK_FRESH_MS)
1846
+ return false;
1847
+ if (!existsSync(lock)) {
1848
+ vanishedStreak += 1;
1849
+ if (vanishedStreak >= 3)
1850
+ return false;
1851
+ }
1852
+ else
1853
+ vanishedStreak = 0;
1854
+ }
1799
1855
  Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()));
1800
1856
  attempt = acquireCommitLock(lock);
1801
1857
  }
@@ -1877,6 +1933,27 @@ export function currentBranch(cwd) {
1877
1933
  const b = gitSafe(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
1878
1934
  return b === "HEAD" ? "" : b; // detached HEAD reports "HEAD" — treat as no branch
1879
1935
  }
1936
+ /** The state files git writes while it is replaying history. `git rev-parse --git-path`
1937
+ * resolves each against THIS worktree's private git dir (a linked worktree keeps its own
1938
+ * rebase-merge/MERGE_HEAD), so a rebase in one worktree never reads as one in another. */
1939
+ const UNSETTLED_HEAD_PATHS = ["rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_LOG"];
1940
+ /** Whether HEAD is somewhere a write must not land: mid rebase/merge/cherry-pick/revert/bisect
1941
+ * ("git-operation-in-progress", which wins when both hold), or detached ("detached-head").
1942
+ * null when HEAD is a settled branch — and also when git cannot answer at all (not a repo,
1943
+ * git missing), so a caller keeps its pre-existing behavior rather than failing closed. */
1944
+ export function gitHeadUnsettled(cwd) {
1945
+ // One spawn: `git rev-parse --git-path a --git-path b …` prints one line per path.
1946
+ const out = gitSafe(["rev-parse", ...UNSETTLED_HEAD_PATHS.flatMap((p) => ["--git-path", p])], cwd);
1947
+ if (!out)
1948
+ return null; // git could not answer (not a repo) — fail open
1949
+ const paths = out.split("\n").map((p) => p.trim()).filter(Boolean);
1950
+ if (paths.length !== UNSETTLED_HEAD_PATHS.length)
1951
+ return null;
1952
+ if (paths.some((p) => existsSync(isAbsolute(p) ? p : resolve(cwd, p))))
1953
+ return "git-operation-in-progress";
1954
+ // `git symbolic-ref -q HEAD` exits non-zero exactly when HEAD is detached.
1955
+ return gitSafe(["symbolic-ref", "-q", "HEAD"], cwd) ? null : "detached-head";
1956
+ }
1880
1957
  /** Files changed in a single commit. `--root` makes the initial commit (which
1881
1958
  * has no parent) report its files as additions instead of returning nothing. */
1882
1959
  export function commitFiles(sha, cwd) {
@@ -51,5 +51,15 @@ export declare function assertCompleteRepoScan(scan: RepoScan): void;
51
51
  * the durable graph merely by inspecting it. Existing public graph records remain
52
52
  * inputs for the same churn/component enrichment semantics as a persisted index. */
53
53
  export declare function scanRepo(store: HunchStore, root: string, opts?: ScanRepoOptions): RepoScan;
54
+ /** True for an edge this indexer produced: the scan re-derives exactly these on
55
+ * every pass, so they — and only they — are safe to replace wholesale. */
56
+ export declare function isExtractorEdge(edge: Edge): boolean;
57
+ /** Merge a fresh scan into the stored edge set. A scan can only re-derive what
58
+ * it extracted; `supersedes` edges (written by the store) and human-reviewed
59
+ * Landscape relationships have no other source, so replacing the whole index
60
+ * with the scan deleted them on every `hunch index` (issue #288). Non-extractor
61
+ * edges are carried forward in their stored order and win an id collision:
62
+ * a reviewed or store-written fact outranks a re-derivable one. */
63
+ export declare function mergeScannedEdges(stored: Edge[], scanned: Edge[]): Edge[];
54
64
  /** Persist one pure scan into the Git-native source of truth. */
55
65
  export declare function indexRepo(store: HunchStore, root: string, opts?: IndexRepoOptions): IndexResult;
@@ -560,6 +560,22 @@ export function scanRepo(store, root, opts = {}) {
560
560
  issues,
561
561
  };
562
562
  }
563
+ /** True for an edge this indexer produced: the scan re-derives exactly these on
564
+ * every pass, so they — and only they — are safe to replace wholesale. */
565
+ export function isExtractorEdge(edge) {
566
+ return edge.schema === "hunch.edge/1" && edge.provenance.source === "extracted";
567
+ }
568
+ /** Merge a fresh scan into the stored edge set. A scan can only re-derive what
569
+ * it extracted; `supersedes` edges (written by the store) and human-reviewed
570
+ * Landscape relationships have no other source, so replacing the whole index
571
+ * with the scan deleted them on every `hunch index` (issue #288). Non-extractor
572
+ * edges are carried forward in their stored order and win an id collision:
573
+ * a reviewed or store-written fact outranks a re-derivable one. */
574
+ export function mergeScannedEdges(stored, scanned) {
575
+ const carried = stored.filter((edge) => !isExtractorEdge(edge));
576
+ const carriedIds = new Set(carried.map((edge) => edge.id));
577
+ return [...carried, ...scanned.filter((edge) => !carriedIds.has(edge.id))];
578
+ }
563
579
  /** Persist one pure scan into the Git-native source of truth. */
564
580
  export function indexRepo(store, root, opts = {}) {
565
581
  if (opts.requireClean) {
@@ -576,7 +592,7 @@ export function indexRepo(store, root, opts = {}) {
576
592
  if (opts.requireComplete)
577
593
  assertCompleteRepoScan(scan);
578
594
  store.json.replaceAll("symbols", scan.symbols);
579
- store.json.replaceAll("edges", scan.edges);
595
+ store.json.replaceAll("edges", mergeScannedEdges(store.json.loadAll("edges"), scan.edges));
580
596
  store.json.replaceAll("components", scan.components);
581
597
  return scan.result;
582
598
  }
@@ -71,6 +71,20 @@ const BARE_TEMPLATE_LINE = /^\s*(?:-\s+)?\{\{[\s\S]*$/;
71
71
  // -- reparenting the item's real children one level up (`containers.name`
72
72
  // instead of `containers[0].name`), silently dropping every reference under it.
73
73
  const BARE_LIST_MARKER = /^(\s*)-[ \t]*\r?$/;
74
+ // A list item whose ENTIRE content is a block-scalar header (`- |`, `- |-`,
75
+ // `- >`, `- |2`, `- | # comment`) -- the idiomatic way a chart inlines a shell
76
+ // script into `args:`/`command:`. The item's value is opaque TEXT, not YAML,
77
+ // yet the body lines look exactly like manifest fields (a heredoc'd
78
+ // `secretKeyRef:` block is the canonical case), and this line matches neither
79
+ // KEY_LINE (no colon) nor BARE_LIST_MARKER (something follows the dash), so it
80
+ // fell to the unrecognized-line branch and the script body was then scanned as
81
+ // the surrounding container's children -- inventing a reference to a resource
82
+ // that only ever existed as text. The `key: |` form needs no such handling:
83
+ // its body nests under the key's OWN frame, where no reference field-path can
84
+ // match. The rest is captured (not `[|>]` inline) so the header can be run
85
+ // through stripTrailingComment before BLOCK_SCALAR_HEADER, exactly as a
86
+ // key line's value is.
87
+ const BLOCK_SCALAR_LIST_ITEM = /^(\s*)-[ \t]+(.*?)\r?$/;
74
88
  // A YAML block-scalar header (`|`, `>`, plus an optional chomping indicator
75
89
  // `+`/`-` and/or an explicit indent digit, in either order: `|`, `|-`, `>+`,
76
90
  // `|2`, `|2-`, `|-2`). When a key's value is JUST this header, the real
@@ -138,8 +152,21 @@ function scanFieldPaths(text, baseChar) {
138
152
  // avoid. Silently ignoring what the scanner can't parse is not safe here;
139
153
  // "I can't tell" must read as "unresolved," the same as a real template.
140
154
  const unresolvedContainers = new Set();
155
+ // The framePath of every container that DIRECTLY holds a key written with no
156
+ // value at all (`tier:`). Deliberately its own set rather than more entries
157
+ // in unresolvedContainers: an ordinary container like `spec:` legitimately
158
+ // holds value-less keys (every nested mapping starts with one), so folding
159
+ // these in would destroy that set's meaning -- "the scanner could not
160
+ // account for this line". Only a map that is supposed to be FLAT
161
+ // (selector/labels) cares, and it checks this set itself.
162
+ const valuelessKeyParents = new Set();
141
163
  const stack = [];
142
164
  let charOffset = baseChar;
165
+ // Indent of the `-` of a `- |` list item whose block-scalar body is being
166
+ // skipped, or null when no such skip is active. The body ends at the first
167
+ // line indented at or shallower than that dash (YAML's own rule for where a
168
+ // sequence item's content stops).
169
+ let blockScalarSkipIndent = null;
143
170
  const popToForListItem = (dashIndent) => {
144
171
  while (stack.length) {
145
172
  const top = stack[stack.length - 1];
@@ -205,6 +232,31 @@ function scanFieldPaths(text, baseChar) {
205
232
  for (const line of text.split("\n")) {
206
233
  const lineStartChar = charOffset;
207
234
  charOffset += line.length + 1; // +1 for the \n split() consumed
235
+ // Inside a `- |` item's body: these lines are script/config TEXT, so they
236
+ // must never be read as field structure. Runs after the charOffset
237
+ // bookkeeping above so skipped lines still advance the offset exactly --
238
+ // every atChar/endChar after the block scalar depends on it.
239
+ if (blockScalarSkipIndent !== null) {
240
+ // A blank line is part of the block scalar (YAML lets a scalar body
241
+ // contain empty lines at any indent), never its terminator.
242
+ if (line.trim().length === 0)
243
+ continue;
244
+ if (BARE_TEMPLATE_LINE.test(line)) {
245
+ // A `{{ }}` action line's own indentation carries no structural
246
+ // meaning (`{{-` chomps it; the `| indent N` idiom puts the action at
247
+ // column 0 -- see markAllOpenContainersUnresolved), so a column-0
248
+ // action inside a script body must NOT be read as a dedent that ends
249
+ // the skip and re-exposes the remaining body lines as fields. Taint
250
+ // conservatively and stay in the skip: we cannot tell whether the
251
+ // action sits inside the scalar or after it.
252
+ markAllOpenContainersUnresolved();
253
+ continue;
254
+ }
255
+ const indent = line.length - line.trimStart().length;
256
+ if (indent > blockScalarSkipIndent)
257
+ continue; // still the scalar's body
258
+ blockScalarSkipIndent = null; // dedented out of the item -- real YAML again
259
+ }
208
260
  if (BARE_TEMPLATE_LINE.test(line)) {
209
261
  markAllOpenContainersUnresolved();
210
262
  continue;
@@ -215,6 +267,18 @@ function scanFieldPaths(text, baseChar) {
215
267
  enterListItem(dashIndent, dashIndent + 1);
216
268
  continue;
217
269
  }
270
+ // Checked BEFORE the KEY_LINE match: KEY_LINE can't match this shape
271
+ // anyway (no colon), but without this branch it would land in the
272
+ // unrecognized-line branch below and the body would be scanned as fields.
273
+ const blockScalarItem = BLOCK_SCALAR_LIST_ITEM.exec(line);
274
+ if (blockScalarItem && BLOCK_SCALAR_HEADER.test(stripTrailingComment(blockScalarItem[2]).trim())) {
275
+ const dashIndent = blockScalarItem[1].length;
276
+ // It IS a real sequence item, so enter it exactly as the bare-marker
277
+ // form does -- sibling items after this one must keep their indices.
278
+ enterListItem(dashIndent, dashIndent + 1);
279
+ blockScalarSkipIndent = dashIndent;
280
+ continue;
281
+ }
218
282
  const m = KEY_LINE.exec(line);
219
283
  if (!m) {
220
284
  // Any other non-blank, non-comment line is a shape this scanner can't
@@ -268,6 +332,18 @@ function scanFieldPaths(text, baseChar) {
268
332
  unresolvedContainers.add(parentPath);
269
333
  continue;
270
334
  }
335
+ // A key written with NO value (`tier:`) creates a frame but never an
336
+ // entry, so it is invisible to extractLiteralLabelMap -- which then
337
+ // returns the map MINUS that key. For a flat string->string map
338
+ // (selector/labels) a value-less direct child is either a null value or a
339
+ // nested container; either way the key is silently absent, and a selector
340
+ // short one key matches strictly MORE workloads than the real one -- the
341
+ // same over-permissive failure as every other unresolved case here.
342
+ // Recorded after the block-scalar branch above, which already taints the
343
+ // parent for its own reason. Note `tier: ""` is NOT value-less: quotes
344
+ // survive stripTrailingComment/trim, so it stays a literal empty string.
345
+ if (value.length === 0)
346
+ valuelessKeyParents.add(parentPath);
271
347
  if (value.length > 0) {
272
348
  const colonIdx = line.indexOf(":", dashIndent);
273
349
  const valueStartInLine = line.indexOf(value, colonIdx);
@@ -309,7 +385,7 @@ function scanFieldPaths(text, baseChar) {
309
385
  // line, since that line's own popToForListItem/popOrdinary call pops
310
386
  // back to (but never past) this key's frame.
311
387
  }
312
- return { entries, unresolvedContainers };
388
+ return { entries, unresolvedContainers, valuelessKeyParents };
313
389
  }
314
390
  function findEntry(entries, path) {
315
391
  return entries.find((e) => e.path === path);
@@ -422,9 +498,14 @@ export const LABELS_PATH_BY_KIND = {
422
498
  * exists, or any direct-child leaf is itself templated -- a partially-literal
423
499
  * map is still unusable for subset-match without evaluating the templated
424
500
  * half, so the whole map is treated as unresolved. */
425
- function extractLiteralLabelMap(prefix, entries, unresolvedContainers) {
501
+ function extractLiteralLabelMap(prefix, entries, unresolvedContainers, valuelessKeyParents) {
426
502
  if (unresolvedContainers.has(prefix))
427
503
  return null;
504
+ // A value-less direct child (`tier:`) never produced an entry at all, so the
505
+ // map below would come back without it -- see valuelessKeyParents' comment
506
+ // in scanFieldPaths for why a short map is the dangerous direction.
507
+ if (valuelessKeyParents.has(prefix))
508
+ return null;
428
509
  // A real Kubernetes label/selector map is always flat (string -> string) --
429
510
  // any entry whose parentPath is a DEEPER descendant of prefix (not prefix
430
511
  // itself) means some direct child of prefix was itself a nested container
@@ -464,7 +545,7 @@ function extractLiteralLabelMap(prefix, entries, unresolvedContainers) {
464
545
  }
465
546
  return pairs.length > 0 ? Object.fromEntries(pairs) : null;
466
547
  }
467
- function buildDocument(text, docStartChar, entries, unresolvedContainers) {
548
+ function buildDocument(text, docStartChar, entries, unresolvedContainers, valuelessKeyParents) {
468
549
  const kindEntry = findEntry(entries, "kind");
469
550
  const kind = kindEntry?.value.form === "literal" ? kindEntry.value.value : null;
470
551
  if (!kind || !ALLOWED_KINDS.has(kind))
@@ -474,20 +555,27 @@ function buildDocument(text, docStartChar, entries, unresolvedContainers) {
474
555
  ? { kind, name: nameEntry.value, startChar: docStartChar, endChar: docStartChar + text.length }
475
556
  : null;
476
557
  const references = [...extractFieldReferences(kind, entries), ...extractOwnerReferenceCandidates(entries)];
477
- const selector = kind === "Service" ? extractLiteralLabelMap("spec.selector", entries, unresolvedContainers) : null;
558
+ const selector = kind === "Service" ? extractLiteralLabelMap("spec.selector", entries, unresolvedContainers, valuelessKeyParents) : null;
478
559
  const labelsPath = LABELS_PATH_BY_KIND[kind];
479
- const labels = labelsPath ? extractLiteralLabelMap(labelsPath, entries, unresolvedContainers) : null;
560
+ const labels = labelsPath ? extractLiteralLabelMap(labelsPath, entries, unresolvedContainers, valuelessKeyParents) : null;
480
561
  return { resource, references, selector, labels };
481
562
  }
482
- // Matches a YAML document-start marker (`---`, optionally with a trailing
483
- // comment -- `--- # second doc` is legal YAML) or a document-end marker
484
- // (`...`). Without the trailing-comment allowance, a commented separator
485
- // silently failed to split at all, merging two documents into one -- the
486
- // later document's fields overwrite the earlier one's (object spread order),
487
- // and the earlier resource's symbol/edges vanish entirely. `----` (four or
488
- // more dashes) is deliberately NOT a separator -- real YAML doesn't treat it
489
- // as one either.
490
- const DOC_SEPARATOR = /^(?:---(?:[ \t]+#.*)?|\.\.\.)[ \t]*\r?$/m;
563
+ // Matches a YAML document-start marker (`---`) or a document-end marker
564
+ // (`...`). Per the YAML spec `---` FOLLOWED BY WHITESPACE starts a document
565
+ // whatever trails it on that line -- a comment (`--- # second doc`), a tag
566
+ // (`--- !!map`), an anchor (`--- &base`) -- so the trailing text is matched
567
+ // generically rather than as a comment only. Without that, a tagged or
568
+ // anchored separator silently failed to split at all, merging two documents
569
+ // into one: the later document's fields overwrite the earlier one's (object
570
+ // spread order), and the earlier resource's symbol/edges vanish entirely.
571
+ // Whatever trails the marker is consumed WITH the boundary -- for a tag or
572
+ // anchor nothing is lost, and inline content written after the marker
573
+ // (`--- kind: Pod`, legal but vanishingly rare) is dropped, which errs toward
574
+ // fewer edges, this module's standing bias. `----` (four or more dashes) is
575
+ // deliberately NOT a separator -- real YAML doesn't treat it as one either,
576
+ // and it still isn't here: the dashes must be followed by whitespace or end
577
+ // of line.
578
+ const DOC_SEPARATOR = /^(?:---(?:[ \t]+.*)?|\.\.\.)[ \t]*\r?$/m;
491
579
  export function extractK8sManifest(source) {
492
580
  const docs = [];
493
581
  const boundaries = [];
@@ -499,8 +587,8 @@ export function extractK8sManifest(source) {
499
587
  const start = starts[i];
500
588
  const end = ends[i];
501
589
  const text = source.slice(start, end);
502
- const { entries, unresolvedContainers } = scanFieldPaths(text, start);
503
- docs.push(buildDocument(text, start, entries, unresolvedContainers));
590
+ const { entries, unresolvedContainers, valuelessKeyParents } = scanFieldPaths(text, start);
591
+ docs.push(buildDocument(text, start, entries, unresolvedContainers, valuelessKeyParents));
504
592
  }
505
593
  return docs;
506
594
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The single rule for "is this hook command Hunch's own?", shared by every hook
3
+ * writer (scaffold.ts for Claude Code's .claude/settings.json, providers.ts for
4
+ * Cursor/Codex/VS Code/Windsurf/Antigravity). One rule, because each writer
5
+ * deletes what it classifies as ours, and a writer that guesses wide deletes the
6
+ * user's hooks (con_8460b6770f).
7
+ */
8
+ /** Does one command string invoke Hunch's own hook?
9
+ *
10
+ * Anchored to the shapes hookCommand() / resolveInvocation() write — a Hunch
11
+ * launcher (the pinned npm package spec, or a …/dist|src/cli/index.js|ts path for
12
+ * source installs) plus a `hook` tail, tokens quoted or bare. The old unanchored
13
+ * /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries like
14
+ * `node ./hook/index.js` as ours and silently deleted them, violating the
15
+ * leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41).
16
+ *
17
+ * `requireProvider` distinguishes the two writers. Provider configs always carry
18
+ * `hook --provider <name>`, so their tail must too; only the LEGACY fully-quoted
19
+ * form (written before the quoting fix, and by hunch versions that predate
20
+ * --provider) may omit it, and its quotes keep it unambiguous. Claude Code's own
21
+ * settings.json never carries --provider, so a bare `hook` tail is all that is
22
+ * left to match on — and a bare tail alone is far too weak, so there the WHOLE
23
+ * command must be one of the two shapes `hunch init` has ever written (see
24
+ * isClaudeCodeHookCommand). */
25
+ export declare function isHunchHookCommand(command: string, requireProvider: boolean): boolean;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The single rule for "is this hook command Hunch's own?", shared by every hook
3
+ * writer (scaffold.ts for Claude Code's .claude/settings.json, providers.ts for
4
+ * Cursor/Codex/VS Code/Windsurf/Antigravity). One rule, because each writer
5
+ * deletes what it classifies as ours, and a writer that guesses wide deletes the
6
+ * user's hooks (con_8460b6770f).
7
+ */
8
+ /** Does one command string invoke Hunch's own hook?
9
+ *
10
+ * Anchored to the shapes hookCommand() / resolveInvocation() write — a Hunch
11
+ * launcher (the pinned npm package spec, or a …/dist|src/cli/index.js|ts path for
12
+ * source installs) plus a `hook` tail, tokens quoted or bare. The old unanchored
13
+ * /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries like
14
+ * `node ./hook/index.js` as ours and silently deleted them, violating the
15
+ * leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41).
16
+ *
17
+ * `requireProvider` distinguishes the two writers. Provider configs always carry
18
+ * `hook --provider <name>`, so their tail must too; only the LEGACY fully-quoted
19
+ * form (written before the quoting fix, and by hunch versions that predate
20
+ * --provider) may omit it, and its quotes keep it unambiguous. Claude Code's own
21
+ * settings.json never carries --provider, so a bare `hook` tail is all that is
22
+ * left to match on — and a bare tail alone is far too weak, so there the WHOLE
23
+ * command must be one of the two shapes `hunch init` has ever written (see
24
+ * isClaudeCodeHookCommand). */
25
+ export function isHunchHookCommand(command, requireProvider) {
26
+ if (!requireProvider)
27
+ return isClaudeCodeHookCommand(command);
28
+ const published = /@davesheffer\/hunch/.test(command);
29
+ const launcher = published || /(?:dist|src)[\\/]+cli[\\/]+index\.(?:js|ts)(?=["\s]|$)/.test(command);
30
+ const legacyTail = /\s"hook"(?:\s+"--provider"\s+"[a-z]+")?\s*$/.test(command);
31
+ return launcher && (legacyTail || /\s"?hook"?\s+"?--provider"?\s+"?[a-z]+"?\s*$/.test(command));
32
+ }
33
+ const ABSOLUTE = String.raw `(?:\/|[A-Za-z]:[\\/]|\\\\)`;
34
+ const CLI_ENTRY = String.raw `[\\/](?:dist|src)[\\/]+cli[\\/]+index\.(?:js|ts)`;
35
+ const SOURCE_HOOK = new RegExp(String.raw `^\s*(?:"?npx(?:\.cmd)?"?\s+"?tsx"?|"[^"]+"|\S+)\s+(?:"${ABSOLUTE}[^"]*${CLI_ENTRY}"|${ABSOLUTE}\S*${CLI_ENTRY})\s+"?hook"?\s*$`);
36
+ /** The bare-`hook` shapes written into .claude/settings.json, matched as a whole
37
+ * command so nothing chained before or after one can be swept out with it:
38
+ *
39
+ * - published: `npx -y --package=[hunch-exact@npm:]@davesheffer/hunch[@v] hunch hook`
40
+ * - source / `npm link` / dev: `<runtime> <ABSOLUTE …/dist|src/cli/index.js|ts> hook`
41
+ *
42
+ * Quoting is NOT the discriminator. shellInvocation() leaves a safe token bare, so
43
+ * since v1.21.1 a POSIX source install writes `/usr/bin/node /abs/dist/cli/index.js
44
+ * hook` with no quotes at all (quotes appear only for a space or a Windows
45
+ * backslash, and on every token before v1.21.1). Requiring them left our own hook
46
+ * in place and appended another on each `hunch init`. What every version shares is
47
+ * an absolute entry path — resolveInvocation() derives it from import.meta.url —
48
+ * and that is what keeps a tool which merely shares the layout
49
+ * (`node tools/lint/dist/cli/index.js hook`) foreign; deleting it was issue #310.
50
+ * A foreign tool invoked by ABSOLUTE path with this exact layout and a lone `hook`
51
+ * argument is still indistinguishable by string alone. */
52
+ function isClaudeCodeHookCommand(command) {
53
+ const published = /^\s*"?npx(?:\.cmd)?"?\s+/i.test(command)
54
+ && /--package=(?:hunch-exact@npm:)?@davesheffer\/hunch(?:@[^"\s]+)?(?=["\s])/.test(command)
55
+ && /\s"?hunch"?\s+"?hook"?\s*$/.test(command);
56
+ return published || SOURCE_HOOK.test(command);
57
+ }
58
+ //# sourceMappingURL=hookmatch.js.map
@@ -18,6 +18,9 @@ export interface HookInstall {
18
18
  reason?: string;
19
19
  /** What to add to `path` by hand (non-writing actions only). */
20
20
  snippet?: string;
21
+ /** The file already carries a Hunch block, but a dead one: the snippet
22
+ * REPLACES it rather than being added (non-writing actions only). */
23
+ stale?: boolean;
21
24
  }
22
25
  /** Portable invocation for text that lands in a TRACKED file (a husky script, a
23
26
  * committed hooks dir, .pre-commit-config.yaml): the same exact-version npx
@@ -30,7 +33,23 @@ export declare const PORTABLE_HOOK_INVOCATION: string;
30
33
  * or a trailing top-level `if … else … fi` whose every branch ends in
31
34
  * `exec`/`exit` — the shape of the pre-commit framework's generated hook. */
32
35
  export declare function terminalExitLine(content: string): string | null;
33
- type BlockState = "installed" | "unreachable" | "missing";
36
+ type BlockState = "installed" | "unreachable" | "stale" | "missing";
37
+ /** The result of inspecting the command a managed block actually runs (issue #315). */
38
+ export type HookInvocationHealth = {
39
+ ok: true;
40
+ invocation: string;
41
+ } | {
42
+ ok: false;
43
+ invocation: string;
44
+ reason: string;
45
+ };
46
+ /** Whether the command in front of a managed block's subcommand is a Hunch
47
+ * launcher that could actually run (issue #315): a block whose command was
48
+ * hand-edited into something else, or whose absolute node/CLI path no longer
49
+ * exists after a reinstall, is present but dead. `command` is the text BEFORE
50
+ * the subcommand; env assignments and a leading `(` belong to the block's own
51
+ * shape and are stripped. Never spawns anything, never touches the network. */
52
+ export declare function hookInvocationHealth(command: string, root: string): HookInvocationHealth;
34
53
  export declare function installPostCommitHook(root: string, invocation: string, opts?: {
35
54
  private?: boolean;
36
55
  commit?: boolean;
@@ -64,8 +83,13 @@ export interface HookReportEntry {
64
83
  manager: HookManagerKind;
65
84
  /** The file where the block lives (or should live). */
66
85
  path: string;
67
- /** Why an `unreachable` block never runs. */
86
+ /** Why an `unreachable` block never runs, or why a `stale` one could not work. */
68
87
  reason?: string;
88
+ /** Where the hook points: the distinct invocations of its marks' blocks. */
89
+ invocation?: string;
90
+ /** Hunch's own option flags the hook's blocks carry (`--private`, `--commit`,
91
+ * `--strict`) — what a plain `hunch init` re-run would drop. Omitted when none. */
92
+ flags?: string[];
69
93
  }
70
94
  export interface HookReport {
71
95
  postCommit: HookReportEntry;
@@ -74,22 +98,29 @@ export interface HookReport {
74
98
  postCheckout: HookReportEntry;
75
99
  }
76
100
  /** Read-only diagnostic (used by `hunch doctor`): each managed hook's state —
77
- * `installed` (present where git will run it), `unreachable` (present, but
78
- * after an exec/exit or in a manager-owned file git never reaches), or
101
+ * `installed` (present where git will run it, running a Hunch launcher that
102
+ * exists), `unreachable` (present, but after an exec/exit or in a
103
+ * manager-owned file git never reaches), `stale` (present where git runs it,
104
+ * but its command is not a working Hunch launcher — issue #315), or
79
105
  * `missing`. postMerge requires BOTH halves (grounding-refresh and
80
106
  * repair-provenance) — a repo carrying only one is a partial install, same as
81
107
  * `installPostMergeHook` self-healing it. Never writes anything. */
82
108
  export declare function hookReport(root: string): HookReport;
83
109
  /** Boolean view of `hookReport`: a hook counts as installed only when its
84
- * managed block is present where git will actually run it, regardless of
85
- * whether the invocation inside it happens to be stale. An unreachable block
86
- * (issue #311) is NOT installed. */
110
+ * managed block is present where git will actually run it AND the command
111
+ * inside it is a Hunch launcher that exists. An unreachable block (issue #311)
112
+ * and a stale one (issue #315) are both NOT installed. */
87
113
  export declare function hookStatus(root: string): {
88
114
  postCommit: boolean;
89
115
  preCommit: boolean;
90
116
  postMerge: boolean;
91
117
  postCheckout: boolean;
92
118
  };
119
+ /** Doctor lines for where each installed/stale hook points (issue #315): hooks
120
+ * sharing one invocation share one line, and a line whose invocation is not the
121
+ * Hunch currently running says so. Informational — a hook may legitimately
122
+ * point at a different install. Pure; the caller adds indentation and dim(). */
123
+ export declare function hookInvocationLines(report: HookReport, running: string): string[];
93
124
  /** CLI lines for one install result: the usual ✓ line when the block is in a
94
125
  * file git runs, otherwise a warning with the reason and the snippet to add to
95
126
  * the manager's own file. */