@davesheffer/hunch 1.46.0 → 1.47.1

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 (78) hide show
  1. package/dist/cli/index.js +280 -358
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/cli/taskReport.d.ts +6 -0
  5. package/dist/cli/taskReport.js +31 -8
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/sourceMutation.js +28 -18
  8. package/dist/core/commitrepair.d.ts +43 -138
  9. package/dist/core/commitrepair.js +76 -167
  10. package/dist/core/correction.d.ts +2 -0
  11. package/dist/core/correction.js +23 -10
  12. package/dist/core/docanchors.js +9 -9
  13. package/dist/core/drift.js +22 -27
  14. package/dist/core/escalations.d.ts +16 -58
  15. package/dist/core/escalations.js +39 -121
  16. package/dist/core/glob.d.ts +7 -7
  17. package/dist/core/glob.js +12 -11
  18. package/dist/core/groundingLag.d.ts +2 -9
  19. package/dist/core/groundingLag.js +5 -11
  20. package/dist/core/groundingMerge.d.ts +2 -7
  21. package/dist/core/groundingMerge.js +35 -54
  22. package/dist/core/paths.d.ts +17 -24
  23. package/dist/core/paths.js +37 -43
  24. package/dist/core/refrepair.d.ts +2 -1
  25. package/dist/core/refrepair.js +4 -4
  26. package/dist/core/repairqueue.d.ts +17 -34
  27. package/dist/core/repairqueue.js +48 -98
  28. package/dist/core/taskRecord.d.ts +1 -1
  29. package/dist/core/taskRecord.js +2 -2
  30. package/dist/core/taskReport.d.ts +8 -3
  31. package/dist/core/taskReport.js +7 -2
  32. package/dist/core/taskReportEvidence.d.ts +7 -0
  33. package/dist/core/taskReportEvidence.js +16 -4
  34. package/dist/core/taskReportHook.js +2 -2
  35. package/dist/core/taskReportPublic.js +2 -2
  36. package/dist/core/taskReportRender.js +2 -2
  37. package/dist/core/types.d.ts +3 -3
  38. package/dist/core/types.js +1 -1
  39. package/dist/extractors/diff.d.ts +4 -4
  40. package/dist/extractors/diff.js +25 -29
  41. package/dist/extractors/git.d.ts +18 -23
  42. package/dist/extractors/git.js +72 -64
  43. package/dist/extractors/helm.d.ts +10 -0
  44. package/dist/extractors/helm.js +43 -59
  45. package/dist/extractors/indexer.d.ts +6 -0
  46. package/dist/extractors/indexer.js +126 -178
  47. package/dist/extractors/k8sManifest.d.ts +23 -80
  48. package/dist/extractors/k8sManifest.js +319 -690
  49. package/dist/extractors/languages.d.ts +28 -37
  50. package/dist/extractors/languages.js +55 -54
  51. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  52. package/dist/extractors/nativeTreeSitter.js +17 -14
  53. package/dist/extractors/parse.d.ts +7 -13
  54. package/dist/extractors/parse.js +39 -51
  55. package/dist/integrations/claudemd.js +1 -0
  56. package/dist/integrations/gitignore.js +3 -5
  57. package/dist/integrations/health.js +1 -1
  58. package/dist/integrations/hooks.d.ts +9 -14
  59. package/dist/integrations/hooks.js +19 -28
  60. package/dist/integrations/mergeDriver.js +13 -14
  61. package/dist/integrations/probe.d.ts +11 -0
  62. package/dist/integrations/probe.js +29 -4
  63. package/dist/integrations/sync.d.ts +16 -4
  64. package/dist/integrations/sync.js +54 -3
  65. package/dist/integrations/workspaceLedger.d.ts +1 -1
  66. package/dist/mcp/server.d.ts +4 -5
  67. package/dist/mcp/server.js +71 -83
  68. package/dist/mcp/taskReportTools.js +4 -4
  69. package/dist/serve/app.js +2 -2
  70. package/dist/store/hunchStore.js +12 -18
  71. package/dist/synthesis/provider.d.ts +19 -21
  72. package/dist/synthesis/provider.js +67 -76
  73. package/dist/synthesis/synthesize.d.ts +8 -8
  74. package/dist/synthesis/synthesize.js +32 -49
  75. package/dist/taskReports.d.ts +1 -0
  76. package/dist/taskReports.js +2 -2
  77. package/package.json +2 -2
  78. package/server.json +2 -2
@@ -3,12 +3,11 @@ import { join } from "node:path";
3
3
  import { existsSync, realpathSync, statSync } from "node:fs";
4
4
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
5
  export const HUNCH_DIR = ".hunch";
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,
6
+ /** Convert a user- or agent-supplied path/target to forward-slash form. Hunch
7
+ * stores every path with "/" (git emits it on every OS), so a Windows-style
8
+ * backslash path would otherwise never match the stored form. Harmless on
9
+ * symbol names, which carry no backslashes. It does NOT make a path
10
+ * repo-relative: an absolute path only has its separators flipped and is left
12
11
  * unchanged otherwise; use `repoRelativeTarget` for that.
13
12
  *
14
13
  * Inherently ambiguous for a string like `docs/notes\notes.md`: it could be a
@@ -21,47 +20,42 @@ export const HUNCH_DIR = ".hunch";
21
20
  export function toPosixTarget(target) {
22
21
  return target.replace(/\\/g, "/").replace(/^\.\//, "");
23
22
  }
24
- /** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
25
- * to Write, or a glob whose concrete segments aren't literal): resolve the longest
26
- * existing ancestor, then re-append the missing tail. Idempotent on an
27
- * already-resolved path. */
23
+ /** Canonical real path of `p`, tolerant of paths that do not exist yet (a file
24
+ * about to be written, a glob with non-literal segments): the longest existing
25
+ * ancestor is resolved through the native realpath and the missing trailing
26
+ * segments are re-appended unchanged. Terminates at the filesystem root,
27
+ * is idempotent on a resolved path and never throws for a missing one. */
28
28
  export function realpathNorm(p) {
29
- try {
30
- return realpathSync.native(p);
31
- }
32
- catch {
33
- const parent = dirname(p);
34
- if (parent === p)
35
- return p; // hit the root; nothing more to resolve
36
- return join(realpathNorm(parent), basename(p));
29
+ const tail = [];
30
+ let cur = p;
31
+ for (;;) {
32
+ try {
33
+ const real = realpathSync.native(cur);
34
+ return tail.length ? join(real, ...tail.reverse()) : real;
35
+ }
36
+ catch {
37
+ const parent = dirname(cur);
38
+ if (parent === cur)
39
+ return p;
40
+ tail.push(basename(cur));
41
+ cur = parent;
42
+ }
37
43
  }
38
44
  }
39
- /** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
40
- * `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
41
- * repo-relative, so a caller that hands over an absolute edit-payload path
42
- * matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
43
- * returned empty for an absolute target that plainly had a matching rule). A
44
- * relative target, a glob, or an absolute path outside `root` passes through
45
- * unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
46
- *
47
- * BOTH ends are realpath-normalized before the relative-path computation: on
48
- * macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
49
- * hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
50
- * bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
51
- * caller treats the file as outside the repo, silently dropping all context
52
- * (dec_e0a36efbf5). */
45
+ /** Rewrite an ABSOLUTE `target` to repo-relative forward-slash form when it lies
46
+ * inside `root`. Stored Hunch paths are always repo-relative, so an absolute
47
+ * path lifted from an edit payload otherwise matches nothing (issue #296).
48
+ * Both ends go through `realpathNorm`, so a symlinked root (macOS /var vs
49
+ * /private/var, /tmp, a symlinked home) cancels out (dec_e0a36efbf5). Anything
50
+ * with no safe repo-relative form (the root itself, an escape, a drive-letter
51
+ * remainder) is returned in its normalized form unchanged. */
53
52
  export function repoRelativeTarget(target, root) {
54
- const t = toPosixTarget(target);
55
- const looksAbsolute = isAbsolute(t) || /^[a-zA-Z]:/.test(t);
56
- if (!looksAbsolute)
57
- return t;
58
- // `root` is posix'd too, not just `target`: realpathNorm's fallback walk decomposes
59
- // by "/" (via node:path, which on a POSIX host does NOT treat "\" as a separator),
60
- // so an un-posix'd root and a posix'd target would decompose into differently-shaped
61
- // ancestor chains and never share a common realpath'd prefix.
62
- const rel = toPosixTarget(relative(realpathNorm(toPosixTarget(root)), realpathNorm(t)));
63
- if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
64
- return t;
53
+ const norm = toPosixTarget(target);
54
+ if (!isAbsolute(norm) && !/^[A-Za-z]:/.test(norm))
55
+ return norm;
56
+ const rel = relative(realpathNorm(toPosixTarget(root)), realpathNorm(norm)).replace(/\\/g, "/");
57
+ if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[A-Za-z]:/.test(rel))
58
+ return norm;
65
59
  return rel;
66
60
  }
67
61
  /** True when `target` (already repo-relative POSIX form) names a regular FILE
@@ -12,10 +12,11 @@ export interface DecisionReferenceRepair {
12
12
  relatedFiles: number;
13
13
  evidence: number;
14
14
  }
15
- export declare function replaceExact(values: string[], from: string, to: string): {
15
+ export type ExactReplacement = {
16
16
  values: string[];
17
17
  changed: number;
18
18
  };
19
+ export declare function replaceExact(refs: string[], target: string, replacement: string): ExactReplacement;
19
20
  /**
20
21
  * Return a corrected copy of a decision, or `null` when the source reference is
21
22
  * not present. The decision's semantic content and verification timestamp are
@@ -1,10 +1,10 @@
1
- export function replaceExact(values, from, to) {
1
+ export function replaceExact(refs, target, replacement) {
2
2
  let changed = 0;
3
- const replaced = values.map((value) => {
4
- if (value !== from)
3
+ const replaced = refs.map((value) => {
4
+ if (value !== target)
5
5
  return value;
6
6
  changed++;
7
- return to;
7
+ return replacement;
8
8
  });
9
9
  // A decision may already cite the destination. Keep the reference list a set
10
10
  // after the repair so a correction cannot create duplicate scope/evidence.
@@ -1,40 +1,23 @@
1
- import { type CommitRewrite, type DroppedRewrite } from "./commitrepair.js";
2
- /** Tolerant read: a missing or corrupt queue file is treated as empty — this
3
- * is local scratch state, never a source of truth worth failing loudly over. */
1
+ import type { CommitRewrite, DroppedRewrite } from "./commitrepair.js";
2
+ /** The raw queue. Missing, corrupt or non-array files read as `[]`; never throws. */
4
3
  export declare function readPendingRepairs(root: string): CommitRewrite[];
4
+ /** Atomically replace the queue. Writing `[]` clears it. */
5
5
  export declare function writePendingRepairs(root: string, rewrites: readonly CommitRewrite[]): void;
6
- /** Tolerant read, same discipline as readPendingRepairs. */
6
+ /** The tombstones of rewrites rejected with `--drop`. Same tolerance as the queue. */
7
7
  export declare function readDroppedRepairs(root: string): DroppedRewrite[];
8
+ /** Atomically replace the tombstone file. Writing `[]` clears it. */
8
9
  export declare function writeDroppedRepairs(root: string, dropped: readonly DroppedRewrite[]): void;
9
- /** The queue as every READ-ONLY consumer should see it: raw entries minus
10
- * anything a human already rejected via `--drop`, regardless of how a
11
- * tombstoned entry ended up back in the raw queue file (e.g. the post-merge
12
- * hook's backgrounded detection racing a `--drop`). A read-time filter, not
13
- * a mutation — the raw queue file is untouched.
14
- *
15
- * `repair-provenance`'s own action is the one exception: it needs the RAW
16
- * read (via readPendingRepairs) because it diffs raw-vs-swept to report what
17
- * it cleaned up, and it persists the sweep back to disk. Every other
18
- * reader — `hunch escalations`, SessionStart orientation, the `hunch_now`
19
- * and `hunch_escalations` MCP tools — must call this instead, or a
20
- * tombstoned entry that hasn't yet been swept by a `repair-provenance` run
21
- * re-surfaces as if the human never answered it. */
10
+ /**
11
+ * The raw queue minus anything tombstoned. Read-only: it never changes either
12
+ * file. Every read-only consumer uses this -- `hunch escalations`, SessionStart
13
+ * orientation, and the `hunch_now` and `hunch_escalations` MCP tools. Only
14
+ * `repair-provenance` itself reads the raw queue.
15
+ */
22
16
  export declare function readActivePendingRepairs(root: string): CommitRewrite[];
23
- /** The `queued` entries whose proposed `to` doesn't resolve to a real commit
24
- * in this repository — the same withheldForUnresolvableTo gate
25
- * `repair-provenance --apply` itself runs before writing, surfaced here so
26
- * a READ-ONLY consumer (hunch escalations, hunch_now/hunch_escalations,
27
- * SessionStart orientation) can tell a permanently-stuck entry apart from
28
- * one `--apply` can still resolve, instead of advertising a
29
- * `--apply --only <id>` that's guaranteed to no-op forever. `null` from
30
- * commitsExist (the check itself failed to run) is treated as fail-open —
31
- * same discipline as the apply path — so nothing is misreported as
32
- * withheld just because the check couldn't run.
33
- *
34
- * Returns the OBJECTS, not their ids, and every caller must pass the exact
35
- * same `queued` array into both this function and whatever it hands the
36
- * result to (commitRepairEscalations) — an id-keyed set would collapse a
37
- * corrupted queue's two same-id entries (one resolvable, one not) into one
38
- * unit, exactly the identity confusion withheldForUnresolvableTo's own
39
- * docstring warns about. */
17
+ /**
18
+ * The queued entries whose proposed `to` does not resolve in this repository.
19
+ * Fails open (nothing withheld) when git cannot answer. The set holds the
20
+ * caller's own objects, so callers must pass the same `queued` array here and to
21
+ * whatever consumes the result.
22
+ */
40
23
  export declare function withheldRewrites(root: string, queued: readonly CommitRewrite[]): Set<CommitRewrite>;
@@ -1,131 +1,81 @@
1
1
  /**
2
- * Local-only queue of commit-provenance repairs detected opportunistically
3
- * (typically by the post-merge hook, src/integrations/hooks.ts) but not yet
4
- * applied. `.hunch/pending-commit-repairs.json` is gitignored the same way
5
- * `.hunch/local.json` is — never committed, never pushed. It exists because
6
- * the opportunistic detection window is narrow: `ORIG_HEAD` gets overwritten
7
- * by the next merge, and the matched-away commit can eventually be gc'd —
8
- * queuing the exact {id, from, to} triple here lets a human confirm the match
9
- * later (`hunch repair-provenance --apply`) without losing it.
2
+ * Local-only queue of detected-but-not-yet-applied commit-provenance repairs.
10
3
  *
11
- * `.hunch/dropped-commit-repairs.json` is the sibling tombstone file: a human
12
- * rejecting a queued match via `--drop` records the exact {id, from, to}
13
- * triple here, durably, so detection re-deriving the identical match on a
14
- * later merge doesn't re-queue what was already rejected — a genuinely
15
- * different replacement (`to`) for the same still-orphaned commit is a new
16
- * proposal and still surfaces (see commitrepair.ts's withoutDropped). Same
17
- * local-only, gitignored discipline as the queue.
4
+ * It is filled opportunistically, usually by the post-merge hook, and is
5
+ * gitignored like `.hunch/local.json` -- never committed. It exists because the
6
+ * detection window is narrow: `ORIG_HEAD` gets overwritten and the matched-away
7
+ * commit can be garbage-collected. A human confirms later with
8
+ * `hunch repair-provenance --apply`. A sibling tombstone file records the exact
9
+ * `{id, from, to}` triples rejected with `--drop`, so re-detection does not
10
+ * re-queue them.
18
11
  */
19
12
  import { existsSync, readFileSync } from "node:fs";
20
13
  import { join } from "node:path";
21
14
  import { writeFileAtomic } from "./io.js";
22
15
  import { withoutDropped, withheldForUnresolvableTo } from "./commitrepair.js";
23
16
  import { commitsExist } from "../extractors/git.js";
24
- function queuePath(root) {
25
- return join(root, ".hunch", "pending-commit-repairs.json");
26
- }
27
- function droppedPath(root) {
28
- return join(root, ".hunch", "dropped-commit-repairs.json");
29
- }
30
- function isCommitRewrite(value) {
31
- if (!value || typeof value !== "object")
17
+ const queuePath = (root) => join(root, ".hunch", "pending-commit-repairs.json");
18
+ const droppedPath = (root) => join(root, ".hunch", "dropped-commit-repairs.json");
19
+ const nonEmpty = (v) => typeof v === "string" && v.length > 0;
20
+ /**
21
+ * Shape-only validation: non-empty string id/from/to, and `to !== from` (a no-op
22
+ * is rejected). There is deliberately no hex check -- a bad `to` is caught later
23
+ * by the apply-time existence check, so this loader stays shape-only.
24
+ */
25
+ function isRewrite(v) {
26
+ if (typeof v !== "object" || v === null)
32
27
  return false;
33
- const { id, from, to } = value;
34
- // Non-empty, and to !== from — an empty target is never a useful rewrite,
35
- // and a no-op entry would apply "successfully" while accomplishing nothing.
36
- // Deliberately NOT a hex-shape check: from/to are opaque strings elsewhere
37
- // in this codebase's test fixtures, and repairDecisionCommit's own
38
- // `d.commit === mine.from` guard already makes a garbage `from` inert (it
39
- // can never match a real decision). A garbage `to` is caught elsewhere,
40
- // not here: `hunch repair-provenance --apply` runs commitsExist against
41
- // every `to` it's actually about to act on (the whole queue, or just the
42
- // one entry `--only <id>` targets) before repairDecisionCommit ever sees
43
- // it, and leaves a non-resolving entry queued rather than applying it
44
- // (withheldForUnresolvableTo, src/core/commitrepair.ts) — with the caveat
45
- // that commitsExist itself fails OPEN if the check can't run at all (not a
46
- // git repo, git missing, timeout), same as drift.ts's own use of it. This
47
- // filter stays shape-only on purpose — the existence check needs a
48
- // repository to run against, which this read-only queue loader doesn't
49
- // have.
50
- return typeof id === "string" && !!id
51
- && typeof from === "string" && !!from
52
- && typeof to === "string" && !!to
53
- && to !== from;
28
+ const r = v;
29
+ return nonEmpty(r.id) && nonEmpty(r.from) && nonEmpty(r.to) && r.to !== r.from;
54
30
  }
55
- function isDroppedRewrite(value) {
56
- if (!value || typeof value !== "object")
31
+ function isDropped(v) {
32
+ if (typeof v !== "object" || v === null)
57
33
  return false;
58
- const { id, from, to } = value;
59
- return typeof id === "string" && !!id
60
- && typeof from === "string" && !!from
61
- && typeof to === "string" && !!to;
34
+ const r = v;
35
+ return nonEmpty(r.id) && nonEmpty(r.from) && nonEmpty(r.to);
62
36
  }
63
- /** Tolerant read: a missing or corrupt queue file is treated as empty — this
64
- * is local scratch state, never a source of truth worth failing loudly over. */
65
- export function readPendingRepairs(root) {
66
- const path = queuePath(root);
67
- if (!existsSync(path))
37
+ function readList(file, guard) {
38
+ if (!existsSync(file))
68
39
  return [];
69
40
  try {
70
- const parsed = JSON.parse(readFileSync(path, "utf8"));
71
- return Array.isArray(parsed) ? parsed.filter(isCommitRewrite) : [];
41
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
42
+ return Array.isArray(parsed) ? parsed.filter(guard) : [];
72
43
  }
73
44
  catch {
74
45
  return [];
75
46
  }
76
47
  }
48
+ /** The raw queue. Missing, corrupt or non-array files read as `[]`; never throws. */
49
+ export function readPendingRepairs(root) {
50
+ return readList(queuePath(root), isRewrite);
51
+ }
52
+ /** Atomically replace the queue. Writing `[]` clears it. */
77
53
  export function writePendingRepairs(root, rewrites) {
78
54
  writeFileAtomic(queuePath(root), JSON.stringify(rewrites, null, 2) + "\n");
79
55
  }
80
- /** Tolerant read, same discipline as readPendingRepairs. */
56
+ /** The tombstones of rewrites rejected with `--drop`. Same tolerance as the queue. */
81
57
  export function readDroppedRepairs(root) {
82
- const path = droppedPath(root);
83
- if (!existsSync(path))
84
- return [];
85
- try {
86
- const parsed = JSON.parse(readFileSync(path, "utf8"));
87
- return Array.isArray(parsed) ? parsed.filter(isDroppedRewrite) : [];
88
- }
89
- catch {
90
- return [];
91
- }
58
+ return readList(droppedPath(root), isDropped);
92
59
  }
60
+ /** Atomically replace the tombstone file. Writing `[]` clears it. */
93
61
  export function writeDroppedRepairs(root, dropped) {
94
62
  writeFileAtomic(droppedPath(root), JSON.stringify(dropped, null, 2) + "\n");
95
63
  }
96
- /** The queue as every READ-ONLY consumer should see it: raw entries minus
97
- * anything a human already rejected via `--drop`, regardless of how a
98
- * tombstoned entry ended up back in the raw queue file (e.g. the post-merge
99
- * hook's backgrounded detection racing a `--drop`). A read-time filter, not
100
- * a mutation — the raw queue file is untouched.
101
- *
102
- * `repair-provenance`'s own action is the one exception: it needs the RAW
103
- * read (via readPendingRepairs) because it diffs raw-vs-swept to report what
104
- * it cleaned up, and it persists the sweep back to disk. Every other
105
- * reader — `hunch escalations`, SessionStart orientation, the `hunch_now`
106
- * and `hunch_escalations` MCP tools — must call this instead, or a
107
- * tombstoned entry that hasn't yet been swept by a `repair-provenance` run
108
- * re-surfaces as if the human never answered it. */
64
+ /**
65
+ * The raw queue minus anything tombstoned. Read-only: it never changes either
66
+ * file. Every read-only consumer uses this -- `hunch escalations`, SessionStart
67
+ * orientation, and the `hunch_now` and `hunch_escalations` MCP tools. Only
68
+ * `repair-provenance` itself reads the raw queue.
69
+ */
109
70
  export function readActivePendingRepairs(root) {
110
71
  return withoutDropped(readPendingRepairs(root), readDroppedRepairs(root));
111
72
  }
112
- /** The `queued` entries whose proposed `to` doesn't resolve to a real commit
113
- * in this repository — the same withheldForUnresolvableTo gate
114
- * `repair-provenance --apply` itself runs before writing, surfaced here so
115
- * a READ-ONLY consumer (hunch escalations, hunch_now/hunch_escalations,
116
- * SessionStart orientation) can tell a permanently-stuck entry apart from
117
- * one `--apply` can still resolve, instead of advertising a
118
- * `--apply --only <id>` that's guaranteed to no-op forever. `null` from
119
- * commitsExist (the check itself failed to run) is treated as fail-open —
120
- * same discipline as the apply path — so nothing is misreported as
121
- * withheld just because the check couldn't run.
122
- *
123
- * Returns the OBJECTS, not their ids, and every caller must pass the exact
124
- * same `queued` array into both this function and whatever it hands the
125
- * result to (commitRepairEscalations) — an id-keyed set would collapse a
126
- * corrupted queue's two same-id entries (one resolvable, one not) into one
127
- * unit, exactly the identity confusion withheldForUnresolvableTo's own
128
- * docstring warns about. */
73
+ /**
74
+ * The queued entries whose proposed `to` does not resolve in this repository.
75
+ * Fails open (nothing withheld) when git cannot answer. The set holds the
76
+ * caller's own objects, so callers must pass the same `queued` array here and to
77
+ * whatever consumes the result.
78
+ */
129
79
  export function withheldRewrites(root, queued) {
130
80
  const existing = commitsExist(queued.map((r) => r.to), root);
131
81
  return new Set(withheldForUnresolvableTo(queued, existing).withheld);
@@ -35,7 +35,7 @@ export declare function computeSupersedes(record: TaskRecord, others: readonly T
35
35
  export interface PersistedTaskRecord {
36
36
  record: TaskRecord;
37
37
  home: TaskRecordHome;
38
- flushed: "pushed" | "committed" | null;
38
+ flushed: "pushed" | "committed" | "held" | null;
39
39
  /** False when the same report revision was already in the graph. */
40
40
  changed: boolean;
41
41
  }
@@ -15,7 +15,7 @@ import { join } from "node:path";
15
15
  import { flushCapture } from "../integrations/sync.js";
16
16
  import { hunchPaths } from "./paths.js";
17
17
  import { episodeTasks, isEmptyTaskReport, readTaskReport, reportHash, sessionsOverlap } from "./taskReport.js";
18
- import { reportSourceSnapshot } from "./taskReportEvidence.js";
18
+ import { currentSource } from "./taskReportEvidence.js";
19
19
  import { ENTITY_KINDS, TaskRecordSchema } from "./types.js";
20
20
  import { refreshRankEval } from "./taskRankingMode.js";
21
21
  import { gitTouchedFiles } from "./taskTouched.js";
@@ -172,7 +172,7 @@ export function computeSupersedes(record, others, limit = 20) {
172
172
  export function persistTaskRecord(root, store, taskId, options = {}) {
173
173
  if (!taskRecordsEnabled(root))
174
174
  return null;
175
- const snapshot = reportSourceSnapshot(root).hash;
175
+ const snapshot = currentSource(root);
176
176
  const own = readTaskReport(root, taskId, snapshot);
177
177
  if (own.task.state === "open")
178
178
  return null;
@@ -30,6 +30,7 @@ export declare const ReportCheckSchema: z.ZodObject<{
30
30
  snapshot_limitations: z.ZodArray<z.ZodString>;
31
31
  timed_out: z.ZodBoolean;
32
32
  cancelled: z.ZodOptional<z.ZodBoolean>;
33
+ work_root: z.ZodOptional<z.ZodString>;
33
34
  source: z.ZodLiteral<"local-command-runner">;
34
35
  }, z.core.$strict>;
35
36
  export type ReportCheck = z.infer<typeof ReportCheckSchema>;
@@ -321,8 +322,8 @@ export interface TaskSummary {
321
322
  }
322
323
  /** One bounded summary per recent task for status lines and host views; the
323
324
  * card and evidence view remain the authoritative renderings. */
324
- export declare function summarizeTaskReport(root: string, taskId: string, currentSnapshot?: string | null): TaskSummary;
325
- export declare function listTaskSummaries(root: string, limit?: number, currentSnapshot?: string | null): TaskSummary[];
325
+ export declare function summarizeTaskReport(root: string, taskId: string, currentSnapshot?: CurrentSnapshot): TaskSummary;
326
+ export declare function listTaskSummaries(root: string, limit?: number, currentSnapshot?: CurrentSnapshot): TaskSummary[];
326
327
  /** One line for a terminal status line. Empty string when nothing was observed
327
328
  * for the task, so a bare prompt shows no Hunch noise at all. */
328
329
  export declare function renderTaskStatusLine(summary: TaskSummary | null): string;
@@ -348,5 +349,9 @@ export declare function reportActivity(root: string): string;
348
349
  export declare function forgetReportTask(root: string, taskId: string): void;
349
350
  export declare function pruneReportHistory(root: string, olderThanDays?: number): number;
350
351
  export declare function reportPresentationEnabled(root: string): boolean;
351
- export declare function readTaskReport(root: string, taskId: string, currentSnapshot?: string | null): TaskReport;
352
+ /** What a reader compares evidence snapshots against: the report root's current source
353
+ * hash, or a resolver that also hashes another worktree a check ran in. A plain hash
354
+ * never vouches for a check that ran elsewhere. */
355
+ export type CurrentSnapshot = string | null | ((dir?: string) => string | null);
356
+ export declare function readTaskReport(root: string, taskId: string, currentSnapshot?: CurrentSnapshot): TaskReport;
352
357
  export {};
@@ -43,6 +43,9 @@ export const ReportCheckSchema = z.object({
43
43
  snapshot_limitations: z.array(z.string().max(300)).max(16),
44
44
  timed_out: z.boolean(),
45
45
  cancelled: z.boolean().optional(),
46
+ /** Where the command ran, recorded only when that is not this ledger's root (a
47
+ * sibling worktree): its currentness is judged against that tree's source. */
48
+ work_root: z.string().min(1).max(4_096).optional(),
46
49
  source: z.literal("local-command-runner"),
47
50
  }).strict();
48
51
  export const ReportSaveSchema = z.object({
@@ -717,6 +720,7 @@ export function reportPresentationEnabled(root) {
717
720
  }
718
721
  }
719
722
  export function readTaskReport(root, taskId, currentSnapshot = null) {
723
+ const snapshotAt = (dir) => typeof currentSnapshot === "function" ? currentSnapshot(dir) : dir === undefined ? currentSnapshot : null;
720
724
  const { task, events } = taskDb(root, db => transaction(db, () => {
721
725
  const task = readTask(db, root, taskId);
722
726
  const { bytes } = db.prepare("SELECT COALESCE(SUM(length(CAST(body AS BLOB))), 0) AS bytes FROM report_events WHERE task_id = ?").get(taskId);
@@ -743,7 +747,7 @@ export function readTaskReport(root, taskId, currentSnapshot = null) {
743
747
  claims.push({ ...ReportClaimSchema.parse(value), at: event.at, attribution: "agent-reported", supported_by: null });
744
748
  else if (event.kind === "conformance") {
745
749
  const rule = ReportConformanceSchema.parse(value);
746
- conformance.push({ ...rule, event_id: event.event_id, at: event.at, current: currentSnapshot !== null && rule.snapshot === currentSnapshot });
750
+ conformance.push({ ...rule, event_id: event.event_id, at: event.at, current: snapshotAt() !== null && rule.snapshot === snapshotAt() });
747
751
  }
748
752
  else if (event.kind === "save")
749
753
  saves.push({ ...ReportSaveSchema.parse(value), event_id: event.event_id, at: event.at, durability: "local", proofs: [] });
@@ -760,7 +764,8 @@ export function readTaskReport(root, taskId, currentSnapshot = null) {
760
764
  refusals.push({ ...ReportRefusalSchema.parse(value), event_id: event.event_id, at: event.at });
761
765
  else if (event.kind === "check") {
762
766
  const check = ReportCheckSchema.parse(value);
763
- checks.push({ ...check, at: event.at, current: currentSnapshot !== null && check.before_snapshot === currentSnapshot && check.after_snapshot === currentSnapshot });
767
+ const now = snapshotAt(check.work_root);
768
+ checks.push({ ...check, at: event.at, current: now !== null && check.before_snapshot === now && check.after_snapshot === now });
764
769
  }
765
770
  else if (event.kind !== "check-start")
766
771
  throw new Error("unsupported report event kind; update Hunch before reading this report");
@@ -9,6 +9,9 @@ export interface ReportSnapshot {
9
9
  /** Bounded source snapshot, not a committed change proof. Ignored files and
10
10
  * Hunch's own memory/cache are excluded and explicitly disclosed. */
11
11
  export declare function reportSourceSnapshot(root: string): ReportSnapshot;
12
+ /** The current source of `root` for report readers, as a resolver that also hashes the
13
+ * sibling worktree a check ran in (its `work_root`). Each tree is hashed at most once. */
14
+ export declare function currentSource(root: string): (dir?: string) => string | null;
12
15
  /** Evaluate every delivered lesson's DECLARED rule against the files this task
13
16
  * changed (working tree vs HEAD). Deterministic and Hunch-owned: the agent
14
17
  * neither chooses the rule nor submits the verdict. A constraint is judged by its
@@ -28,4 +31,8 @@ export declare function runReportCheck(root: string, taskId: string, command: st
28
31
  signal?: AbortSignal;
29
32
  onStdout?: (chunk: Buffer) => void;
30
33
  onStderr?: (chunk: Buffer) => void;
34
+ /** Where the command runs and whose source is snapshotted, when the task's
35
+ * ledger lives in a sibling worktree of the same repository (the hook opened
36
+ * the task at the main checkout, the agent works in a linked one). */
37
+ workRoot?: string;
31
38
  }): Promise<ReportCheck>;
@@ -87,6 +87,16 @@ export function reportSourceSnapshot(root) {
87
87
  return { hash: null, limitations: [...limitations, "A complete bounded source snapshot was unavailable; no current-change verification is claimed."] };
88
88
  }
89
89
  }
90
+ /** The current source of `root` for report readers, as a resolver that also hashes the
91
+ * sibling worktree a check ran in (its `work_root`). Each tree is hashed at most once. */
92
+ export function currentSource(root) {
93
+ const seen = new Map();
94
+ return (dir = root) => {
95
+ if (!seen.has(dir))
96
+ seen.set(dir, reportSourceSnapshot(dir).hash);
97
+ return seen.get(dir);
98
+ };
99
+ }
90
100
  /** Evaluate every delivered lesson's DECLARED rule against the files this task
91
101
  * changed (working tree vs HEAD). Deterministic and Hunch-owned: the agent
92
102
  * neither chooses the rule nor submits the verdict. A constraint is judged by its
@@ -210,7 +220,8 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
210
220
  const task = readTaskReport(root, taskId).task;
211
221
  if (task.state !== "open" && task.closed_by !== "host")
212
222
  throw new Error("cannot verify a closed task");
213
- const before = reportSourceSnapshot(root);
223
+ const workRoot = options.workRoot ?? root;
224
+ const before = reportSourceSnapshot(workRoot);
214
225
  // Validate sensitive arguments before executing or writing anything.
215
226
  ReportCheckSchema.parse({ label, command, exit_code: null, output_hash: reportHash(""), before_snapshot: before.hash, after_snapshot: null, snapshot_limitations: before.limitations, timed_out: false, source: "local-command-runner" });
216
227
  const checkId = beginReportCheck(root, taskId, label, timeoutMs);
@@ -228,7 +239,7 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
228
239
  const started = (() => {
229
240
  try {
230
241
  const resolved = resolveSpawnCommand(command);
231
- return spawn(resolved.file, resolved.args, { cwd: root, shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32", windowsVerbatimArguments: resolved.windowsVerbatimArguments === true });
242
+ return spawn(resolved.file, resolved.args, { cwd: workRoot, shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32", windowsVerbatimArguments: resolved.windowsVerbatimArguments === true });
232
243
  }
233
244
  catch (error) {
234
245
  // An argument the launcher cannot carry, or a synchronous spawn refusal
@@ -319,9 +330,10 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
319
330
  if (options.signal?.aborted)
320
331
  cancel();
321
332
  });
322
- const after = reportSourceSnapshot(root);
333
+ const after = reportSourceSnapshot(workRoot);
323
334
  const check = { check_id: checkId, label, command, exit_code: result.code, output_hash: result.hash, before_snapshot: before.hash, after_snapshot: after.hash,
324
- snapshot_limitations: [...new Set([...before.limitations, ...after.limitations])], timed_out: result.timedOut, cancelled: result.cancelled, source: "local-command-runner" };
335
+ snapshot_limitations: [...new Set([...before.limitations, ...after.limitations])], timed_out: result.timedOut, cancelled: result.cancelled,
336
+ ...(workRoot !== root ? { work_root: workRoot } : {}), source: "local-command-runner" };
325
337
  recordReportCheck(root, taskId, check);
326
338
  return check;
327
339
  }
@@ -6,7 +6,7 @@ import { findRoot } from "./paths.js";
6
6
  import { canonicalReportRoot } from "./taskReportPaths.js";
7
7
  import { isCredentialFreeText } from "./types.js";
8
8
  import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, reportTaskExists, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
9
- import { reportSourceSnapshot } from "./taskReportEvidence.js";
9
+ import { currentSource } from "./taskReportEvidence.js";
10
10
  import { renderTaskReport } from "./taskReportRender.js";
11
11
  import { verificationLauncher } from "./verifyLauncher.js";
12
12
  import { injectionMode } from "./hookcache.js";
@@ -290,7 +290,7 @@ export function stopHookReport(root, provider, event) {
290
290
  if (id === "legacy")
291
291
  return { systemMessage: "Hunch hook active. This host version does not provide an exact prompt identifier, so contribution for this response is unverified. Explicit task reports remain available with hunch report." };
292
292
  try {
293
- const report = readTaskReport(root, id, reportSourceSnapshot(root).hash);
293
+ const report = readTaskReport(root, id, currentSource(root));
294
294
  if (isEmptyTaskReport(report))
295
295
  return null;
296
296
  // The HTML evidence view is a rendering of the local ledger, generated on
@@ -3,9 +3,9 @@
3
3
  import { JsonStore } from "../store/jsonStore.js";
4
4
  import { hunchPaths } from "./paths.js";
5
5
  import { readTaskReport, reportHash } from "./taskReport.js";
6
- import { reportSourceSnapshot } from "./taskReportEvidence.js";
6
+ import { currentSource } from "./taskReportEvidence.js";
7
7
  export function publicTaskReport(root, taskId) {
8
- const report = readTaskReport(root, taskId, reportSourceSnapshot(root).hash);
8
+ const report = readTaskReport(root, taskId, currentSource(root));
9
9
  const publicStore = new JsonStore(hunchPaths(root));
10
10
  const records = new Map();
11
11
  const deliveries = [];
@@ -1,11 +1,11 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { writeFileAtomic } from "./io.js";
3
3
  import { readTaskReport, readLessonHistory } from "./taskReport.js";
4
- import { reportSourceSnapshot } from "./taskReportEvidence.js";
4
+ import { currentSource } from "./taskReportEvidence.js";
5
5
  import { assertReportPath } from "./taskReportPaths.js";
6
6
  import { publicTaskReport, renderPublicTaskReportHtml } from "./taskReportPublic.js";
7
7
  export function writeTaskReportHtml(root, taskId, publicOnly = false) {
8
- const report = readTaskReport(root, taskId, reportSourceSnapshot(root).hash);
8
+ const report = readTaskReport(root, taskId, currentSource(root));
9
9
  const directory = assertReportPath(root, ".hunch-cache", "reports");
10
10
  mkdirSync(directory, { recursive: true });
11
11
  const file = assertReportPath(root, ".hunch-cache", "reports", `${report.task.task_id}${publicOnly ? ".public" : ""}.html`);