@davesheffer/hunch 1.39.1 → 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 (64) hide show
  1. package/dist/cli/index.js +164 -55
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/client/state.d.ts +2 -1
  5. package/dist/client/state.js +1 -0
  6. package/dist/core/agenthook.js +7 -3
  7. package/dist/core/capturetoken.d.ts +30 -3
  8. package/dist/core/capturetoken.js +29 -3
  9. package/dist/core/correction.d.ts +10 -4
  10. package/dist/core/correction.js +19 -11
  11. package/dist/core/countersign.d.ts +28 -0
  12. package/dist/core/countersign.js +50 -0
  13. package/dist/core/docanchors.js +16 -2
  14. package/dist/core/glob.d.ts +19 -0
  15. package/dist/core/glob.js +37 -0
  16. package/dist/core/paths.d.ts +27 -5
  17. package/dist/core/paths.js +51 -6
  18. package/dist/core/procstart.d.ts +94 -0
  19. package/dist/core/procstart.js +244 -0
  20. package/dist/core/reviewqueue.js +6 -1
  21. package/dist/core/served.d.ts +6 -3
  22. package/dist/core/served.js +10 -6
  23. package/dist/core/spawnCommand.js +41 -9
  24. package/dist/core/stateHttp.d.ts +1 -1
  25. package/dist/core/stateHttp.js +3 -1
  26. package/dist/core/taskReport.d.ts +2 -0
  27. package/dist/core/taskReport.js +46 -11
  28. package/dist/core/taskReportEvidence.js +42 -6
  29. package/dist/core/taskReportHook.js +14 -2
  30. package/dist/core/topics.js +1 -1
  31. package/dist/core/workspace.d.ts +9 -0
  32. package/dist/core/workspace.js +15 -0
  33. package/dist/extractors/git.d.ts +5 -0
  34. package/dist/extractors/git.js +87 -10
  35. package/dist/extractors/indexer.d.ts +10 -0
  36. package/dist/extractors/indexer.js +17 -1
  37. package/dist/extractors/k8sManifest.js +104 -16
  38. package/dist/integrations/hookmatch.d.ts +25 -0
  39. package/dist/integrations/hookmatch.js +58 -0
  40. package/dist/integrations/hooks.d.ts +38 -7
  41. package/dist/integrations/hooks.js +250 -24
  42. package/dist/integrations/providers.js +2 -17
  43. package/dist/integrations/scaffold.d.ts +1 -1
  44. package/dist/integrations/scaffold.js +25 -22
  45. package/dist/integrations/workspaceLedger.d.ts +10 -0
  46. package/dist/integrations/workspaceLedger.js +9 -1
  47. package/dist/mcp/server.d.ts +10 -0
  48. package/dist/mcp/server.js +131 -64
  49. package/dist/serve/app.d.ts +4 -0
  50. package/dist/serve/app.js +56 -36
  51. package/dist/serve/writelock.d.ts +21 -1
  52. package/dist/serve/writelock.js +157 -16
  53. package/dist/store/hunchStore.js +41 -11
  54. package/dist/store/jsonStore.d.ts +6 -4
  55. package/dist/store/jsonStore.js +225 -32
  56. package/dist/store/merge.d.ts +7 -4
  57. package/dist/store/merge.js +65 -5
  58. package/dist/store/privateMigrate.js +10 -0
  59. package/dist/store/stateBinding.js +28 -7
  60. package/dist/wiki/adopt.js +13 -2
  61. package/dist/wiki/wiki.d.ts +7 -0
  62. package/dist/wiki/wiki.js +19 -8
  63. package/package.json +1 -1
  64. package/server.json +2 -2
@@ -25,12 +25,18 @@ export interface CorrectionInput {
25
25
  * consumer matches repo-relative paths — so without this an absolute hint mints a
26
26
  * scope that can never match. */
27
27
  root?: string;
28
- /** True when a capture token was CONSUMED for this write — proof a grilling interview
29
- * preceded it. Determines the TIER, never whether the write lands: an un-vouched
30
- * correction still records immediately and still surfaces at edit time and in CI.
31
- * Only the authority to DENY waits for a countersign. */
28
+ /** True only when a HUMAN confirmed this write outside the agent's channel (an MCP
29
+ * elicitation answered in the client UI). A consumed capture token alone is NOT
30
+ * enough — any agent can mint and consume one. Determines the TIER, never whether
31
+ * the write lands: an un-vouched correction still records immediately and still
32
+ * surfaces at edit time and in CI. Only the authority to DENY waits for a countersign
33
+ * (`hunch review --confirm <id>`). */
32
34
  vouched?: boolean;
33
35
  }
36
+ /** Default rationales, exported so a later human countersign can replace the testimony
37
+ * wording without touching a rationale a person actually wrote. */
38
+ export declare const VOUCHED_CORRECTION_RATIONALE = "Captured from a human correction of the agent (Never Twice).";
39
+ export declare const TESTIMONY_CORRECTION_RATIONALE = "Recorded by the agent as a correction, without a human confirmation \u2014 advisory testimony until a human countersigns it (`hunch review --confirm`) (Never Twice).";
34
40
  /**
35
41
  * Build the Constraint a correction mints. Pure (caller passes `now`), so the
36
42
  * scope/severity policy is testable in isolation. Key safety rule (research
@@ -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, relative } from "node:path";
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",
@@ -41,6 +41,10 @@ export const CORRECTION_NUDGE = "This looks like a correction. If it's a rule th
41
41
  "call hunch_record_correction({ rule, scope_hint_file, severity, applies_to_all }) so it " +
42
42
  "becomes an enforced, scoped constraint (held at edit-time and in CI) — not a one-off the next session forgets. " +
43
43
  "Use severity:\"blocking\" only when the human said never/must; set applies_to_all:true only if the rule is genuinely repo-wide.";
44
+ /** Default rationales, exported so a later human countersign can replace the testimony
45
+ * wording without touching a rationale a person actually wrote. */
46
+ export const VOUCHED_CORRECTION_RATIONALE = "Captured from a human correction of the agent (Never Twice).";
47
+ export const TESTIMONY_CORRECTION_RATIONALE = "Recorded by the agent as a correction, without a human confirmation — advisory testimony until a human countersigns it (`hunch review --confirm`) (Never Twice).";
44
48
  /** Normalize a scope hint to a repo-relative POSIX path.
45
49
  *
46
50
  * An ABSOLUTE hint is the shape an agent naturally sends, but `checkConstraints`
@@ -53,7 +57,13 @@ export const CORRECTION_NUDGE = "This looks like a correction. If it's a rule th
53
57
  * Returns "" when the hint cannot be made repo-relative (no root, or a path outside the
54
58
  * repo). The caller then falls back to "**", where the existing severity guard
55
59
  * down-ranks a non-explicit blocking rule to a warning — fail-safe and honest, rather
56
- * 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. */
57
67
  function repoRelativeHint(rawHint, root) {
58
68
  if (!rawHint)
59
69
  return "";
@@ -62,10 +72,9 @@ function repoRelativeHint(rawHint, root) {
62
72
  return rawHint;
63
73
  if (!root)
64
74
  return "";
65
- const rel = toPosixTarget(relative(root, rawHint));
66
- if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
67
- return "";
68
- return rel;
75
+ const rewritten = repoRelativeTarget(rawHint, root);
76
+ const stillAbsolute = isAbsolute(rewritten) || /^[a-zA-Z]:/.test(rewritten);
77
+ return stillAbsolute ? "" : rewritten;
69
78
  }
70
79
  /**
71
80
  * Build the Constraint a correction mints. Pure (caller passes `now`), so the
@@ -94,7 +103,8 @@ export function buildCorrectionConstraint(input, now) {
94
103
  // the highest-authority write path the least gated one, and strictly worse than
95
104
  // hunch_record_decision, which only ever produced advisory memory and is now tiered.
96
105
  //
97
- // The token sets the TIER, never whether the write lands. An un-vouched correction is
106
+ // A HUMAN confirmation sets the TIER (a capture token alone is not one — any agent can
107
+ // mint and consume a token), never whether the write lands. An un-vouched correction is
98
108
  // still recorded immediately and still held against every assistant at edit time and
99
109
  // in CI — Never Twice keeps its promise. What waits for a countersign is only the
100
110
  // authority to DENY.
@@ -114,9 +124,7 @@ export function buildCorrectionConstraint(input, now) {
114
124
  // rule that goes stale. Validated against the repo's real deps when supplied → never mints a
115
125
  // never-firing rule for a non-dependency. null when nothing derivable → falls back to scope.
116
126
  forbids: deriveForbids(rule, input.knownDeps),
117
- rationale: input.rationale ?? (vouched
118
- ? "Captured from a human correction of the agent (Never Twice)."
119
- : "Recorded by the agent as a correction, WITHOUT a capture interview — advisory testimony until a human countersigns it via /capture (Never Twice)."),
127
+ rationale: input.rationale ?? (vouched ? VOUCHED_CORRECTION_RATIONALE : TESTIMONY_CORRECTION_RATIONALE),
120
128
  source_decision: input.source_decision ?? null,
121
129
  violations: [],
122
130
  status: "active",
@@ -0,0 +1,28 @@
1
+ /** Human countersign: turn agent testimony into a human-confirmed record.
2
+ *
3
+ * A capture token proves an interview protocol was issued, not that a human answered
4
+ * (see capturetoken.ts), so agent-written decisions and corrections land as
5
+ * `agent_recorded` testimony. This is the pure record transform behind the human act
6
+ * that upgrades them — `hunch review --confirm <id>` (and, for a correction, the
7
+ * severity the human grants). It never changes a record's content or status: confirming
8
+ * a proposed decision is not shipping it. Pure (caller passes `now`) so it is testable. */
9
+ import type { Constraint, Decision } from "./types.js";
10
+ /** Is this record agent testimony awaiting a human countersign? Token-aware ("+"-joined
11
+ * sources), and a record carrying a human signature is never testimony. */
12
+ export declare function isAgentTestimony(source: string | undefined): boolean;
13
+ /** The exact command a HUMAN runs to countersign agent testimony (outside the agent
14
+ * channel). `private` targets the overlay home; `severity` grants a correction's authority. */
15
+ export declare function confirmCommand(id: string, opts?: {
16
+ private?: boolean;
17
+ severity?: string;
18
+ }): string;
19
+ /** Replace the agent testimony tier with the human signature, keeping every other
20
+ * "+"-joined source token ("llm_draft+agent_recorded" → "llm_draft+human_confirmed"). */
21
+ export declare function withHumanSignature(source: string): string;
22
+ /** Countersign a decision. Same tier + confidence the capture path grants a human-confirmed
23
+ * write; status, content, and tripwires are untouched (`hunch review --accept` is the path
24
+ * that ships a draft and arms its tripwires). */
25
+ export declare function countersignDecision(d: Decision, now: string): Decision;
26
+ /** Countersign a correction. `severity`, when given, is the authority the human grants
27
+ * (an unconfirmed "blocking" request was capped to "warning"); otherwise it is kept. */
28
+ export declare function countersignConstraint(c: Constraint, now: string, severity?: Constraint["severity"]): Constraint;
@@ -0,0 +1,50 @@
1
+ import { TESTIMONY_CORRECTION_RATIONALE, VOUCHED_CORRECTION_RATIONALE } from "./correction.js";
2
+ /** Is this record agent testimony awaiting a human countersign? Token-aware ("+"-joined
3
+ * sources), and a record carrying a human signature is never testimony. */
4
+ export function isAgentTestimony(source) {
5
+ const tokens = (source ?? "").split("+");
6
+ return tokens.includes("agent_recorded") && !tokens.includes("human_confirmed");
7
+ }
8
+ /** The exact command a HUMAN runs to countersign agent testimony (outside the agent
9
+ * channel). `private` targets the overlay home; `severity` grants a correction's authority. */
10
+ export function confirmCommand(id, opts = {}) {
11
+ return `hunch review --confirm ${id}${opts.severity ? ` --severity ${opts.severity}` : ""}${opts.private ? " --private" : ""}`;
12
+ }
13
+ /** Replace the agent testimony tier with the human signature, keeping every other
14
+ * "+"-joined source token ("llm_draft+agent_recorded" → "llm_draft+human_confirmed"). */
15
+ export function withHumanSignature(source) {
16
+ const tokens = source.split("+").filter((t) => t && t !== "agent_recorded");
17
+ if (!tokens.includes("human_confirmed"))
18
+ tokens.push("human_confirmed");
19
+ return tokens.join("+");
20
+ }
21
+ /** Countersign a decision. Same tier + confidence the capture path grants a human-confirmed
22
+ * write; status, content, and tripwires are untouched (`hunch review --accept` is the path
23
+ * that ships a draft and arms its tripwires). */
24
+ export function countersignDecision(d, now) {
25
+ return {
26
+ ...d,
27
+ provenance: {
28
+ ...d.provenance,
29
+ source: withHumanSignature(d.provenance.source),
30
+ confidence: Math.max(d.provenance.confidence ?? 0, 0.95),
31
+ last_verified: now,
32
+ },
33
+ };
34
+ }
35
+ /** Countersign a correction. `severity`, when given, is the authority the human grants
36
+ * (an unconfirmed "blocking" request was capped to "warning"); otherwise it is kept. */
37
+ export function countersignConstraint(c, now, severity) {
38
+ return {
39
+ ...c,
40
+ severity: severity ?? c.severity,
41
+ rationale: c.rationale === TESTIMONY_CORRECTION_RATIONALE ? VOUCHED_CORRECTION_RATIONALE : c.rationale,
42
+ provenance: {
43
+ ...c.provenance,
44
+ source: withHumanSignature(c.provenance.source),
45
+ confidence: 1,
46
+ last_verified: now,
47
+ },
48
+ };
49
+ }
50
+ //# sourceMappingURL=countersign.js.map
@@ -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;
@@ -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
@@ -1,10 +1,32 @@
1
1
  export declare const HUNCH_DIR = ".hunch";
2
- /** Canonicalize a free-form path/target to repo-relative POSIX form. Hunch stores
3
- * every path with forward slashes (git emits "/" on all OSes), so any user- or
4
- * agent-supplied target must be normalized before comparison — otherwise a
5
- * Windows caller passing `src\auth\session.ts` never matches the stored
6
- * `src/auth/session.ts`. Safe on symbol names too: they contain no backslashes. */
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;
@@ -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 repo-relative POSIX form. Hunch stores
7
- * every path with forward slashes (git emits "/" on all OSes), so any user- or
8
- * agent-supplied target must be normalized before comparison — otherwise a
9
- * Windows caller passing `src\auth\session.ts` never matches the stored
10
- * `src/auth/session.ts`. Safe on symbol names too: they contain no backslashes. */
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 {
@@ -0,0 +1,94 @@
1
+ /** `starttime` in clock ticks since boot, as the exact decimal STRING the kernel
2
+ * printed — it is an identity component, never a quantity, so it is never
3
+ * converted to a Number (which would lose precision above 2^53 and invite
4
+ * arithmetic). `comm` (field 2) may contain spaces and parentheses, so the
5
+ * numeric fields are only unambiguous AFTER the last `)`. starttime is overall
6
+ * field 22, i.e. index 19 of that remainder (whose [0] is the state field). */
7
+ export declare function starttimeTicks(stat: string): string | null;
8
+ /** Reader seam so the pure token logic is testable off linux. */
9
+ interface ProcReader {
10
+ read: (path: string) => string;
11
+ readlink: (path: string) => string;
12
+ }
13
+ /** The process-instance token `<bootId>:<pidNsInum>:<timeNsInum>:<starttimeTicks>`
14
+ * for `pid` ("self" for us), or `null` when any piece is missing or malformed.
15
+ * Linux only, and no spawn: every piece is a file read.
16
+ *
17
+ * The pid namespace is ALWAYS the caller's own (`/proc/self/ns/pid`), including
18
+ * when tokenizing another pid: a pid we can see in our `/proc` is by definition
19
+ * addressed in our namespace, so that is the namespace the token belongs to.
20
+ *
21
+ * The TIME namespace matters because field 22 is rendered relative to the
22
+ * READER's time namespace: the same live process yields different ticks to
23
+ * readers in different time namespaces, so a token compared across them would
24
+ * read "recycled" for a perfectly healthy owner. Kernels before 5.6 have no
25
+ * `/proc/self/ns/time` at all — that one missing link is normalized to "0"
26
+ * (every process on such a kernel shares the same, only, time namespace).
27
+ *
28
+ * `/proc` itself can be a view from an ANCESTOR pid namespace (a bind-mounted
29
+ * or not-remounted /proc inside a container), in which case `/proc/<pid>` names
30
+ * a process by the ancestor's numbering while `kill(pid, 0)` uses ours — two
31
+ * different pid spaces silently mixed. `readlink /proc/self` returns our pid AS
32
+ * /proc numbers it, so requiring it to equal our own `process.pid` is the cheap
33
+ * proof that the mount we are reading is our own namespace's. */
34
+ export declare function linuxStartToken(pid: number | "self", io?: ProcReader, selfPid?: number): string | null;
35
+ /** OUR process-instance token, or `null` off linux / when /proc proves nothing.
36
+ * Only a NON-NULL token is memoized: a process's own identity never changes,
37
+ * but a transient failure to READ it (a momentarily unreadable /proc) must not
38
+ * be frozen into a permanent "we have no identity" for the life of the process. */
39
+ export declare function selfStartToken(): string | null;
40
+ /** The process-instance token of whoever currently holds `pid`, or `null` when
41
+ * that cannot be established (not linux, pid gone, /proc hidden). */
42
+ export declare function startTokenOf(pid: number): string | null;
43
+ /** What the recorded owner is, relative to whoever holds its pid NOW.
44
+ * - `same` — byte-identical token: the genuine owner, still running.
45
+ * - `recycled` — the pid is held by a DIFFERENT process instance.
46
+ * - `unprobeable` — the pid number carries no meaning here (another boot, another
47
+ * namespace, or a token read on a platform that has none), so
48
+ * neither "alive" nor "dead" is evidence.
49
+ * - `unknown` — no token recorded, or none readable now: not proven either way. */
50
+ export type InstanceVerdict = "same" | "recycled" | "unprobeable" | "unknown";
51
+ /** The whole verdict, pure and injectable. String comparison only — no clocks. */
52
+ export declare function ownerInstance(ownerStart: string | undefined, pid: number, self?: string | null, tokenOf?: (pid: number) => string | null, platform?: NodeJS.Platform): InstanceVerdict;
53
+ /** Nonces of the locks THIS process currently holds.
54
+ *
55
+ * The own-pid fallback for platforms without tokens: when the token path says
56
+ * "unknown" and the lock names OUR OWN pid, a nonce we do not hold means the
57
+ * lock is not one WE took — most likely a PREDECESSOR that happened to have this
58
+ * pid (the container-restart shape) — so the record falls to the age rule
59
+ * instead of our own liveness vouching for a corpse forever.
60
+ *
61
+ * This is module state, i.e. per THREAD. `src/` has no `worker_threads` users
62
+ * (verified by grep), so every holder in a process shares this set; if a worker
63
+ * ever takes these locks, this fallback must be revisited. It is also per MODULE
64
+ * INSTANCE: were this module ever loaded twice in one process (a cache-busting
65
+ * import, a duplicated dependency), one copy would not recognise the other's
66
+ * held nonce and could age-steal its live lock. */
67
+ export declare const heldLockNonces: Set<string>;
68
+ /** What a contender should do about a SAME-HOST lock owner.
69
+ * - `stale` — provably not the owner's process any more: take it over.
70
+ * - `live` — authoritative, however old the file looks: never steal it.
71
+ * - `age` — nothing is proven; fall back to the caller's own age rule. */
72
+ export type SameHostVerdict = "stale" | "live" | "age";
73
+ /** The parts of a lock record this decision reads. Both lock formats supply it. */
74
+ export interface SameHostOwner {
75
+ pid: number;
76
+ start?: string;
77
+ nonce?: string;
78
+ }
79
+ /** Everything the decision consults, injected so every row is unit-testable. */
80
+ export interface SameHostEnv {
81
+ alive: (pid: number) => boolean;
82
+ self: string | null;
83
+ tokenOf: (pid: number) => string | null;
84
+ platform: NodeJS.Platform;
85
+ selfPid: number;
86
+ heldNonces: ReadonlySet<string>;
87
+ }
88
+ /**
89
+ * THE decision table, shared by `src/serve/writelock.ts` and jsonStore's
90
+ * `.rmw-lock` so the two locks can never drift apart. Pure: no clock, no fs, no
91
+ * process state beyond what `env` hands it.
92
+ */
93
+ export declare function judgeSameHostOwner(owner: SameHostOwner, env: SameHostEnv): SameHostVerdict;
94
+ export {};