@davesheffer/hunch 1.39.0 → 1.39.2

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 (48) hide show
  1. package/dist/cli/index.js +193 -65
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/client/state.d.ts +2 -1
  4. package/dist/client/state.js +1 -0
  5. package/dist/core/agenthook.d.ts +14 -0
  6. package/dist/core/agenthook.js +55 -8
  7. package/dist/core/capturetoken.d.ts +30 -3
  8. package/dist/core/capturetoken.js +29 -3
  9. package/dist/core/changeProof.js +5 -1
  10. package/dist/core/checkreport.d.ts +7 -0
  11. package/dist/core/checkreport.js +20 -3
  12. package/dist/core/compare.js +3 -2
  13. package/dist/core/correction.d.ts +10 -4
  14. package/dist/core/correction.js +7 -4
  15. package/dist/core/countersign.d.ts +28 -0
  16. package/dist/core/countersign.js +50 -0
  17. package/dist/core/reviewqueue.js +6 -1
  18. package/dist/core/spawnCommand.js +41 -9
  19. package/dist/core/stateHttp.d.ts +1 -1
  20. package/dist/core/stateHttp.js +3 -1
  21. package/dist/core/taskReportEvidence.js +31 -11
  22. package/dist/core/topics.js +1 -1
  23. package/dist/core/types.d.ts +1 -0
  24. package/dist/core/workspace.d.ts +23 -1
  25. package/dist/core/workspace.js +31 -7
  26. package/dist/extractors/diff.d.ts +34 -0
  27. package/dist/extractors/diff.js +147 -5
  28. package/dist/extractors/git.d.ts +40 -11
  29. package/dist/extractors/git.js +147 -43
  30. package/dist/extractors/workspaces.d.ts +10 -0
  31. package/dist/extractors/workspaces.js +92 -15
  32. package/dist/integrations/gitignore.d.ts +27 -2
  33. package/dist/integrations/gitignore.js +103 -17
  34. package/dist/integrations/hooks.d.ts +61 -7
  35. package/dist/integrations/hooks.js +330 -43
  36. package/dist/integrations/scaffold.js +1 -1
  37. package/dist/integrations/workspaceLedger.d.ts +23 -3
  38. package/dist/integrations/workspaceLedger.js +114 -8
  39. package/dist/mcp/server.js +115 -56
  40. package/dist/serve/app.d.ts +4 -0
  41. package/dist/serve/app.js +56 -36
  42. package/dist/store/hunchStore.d.ts +4 -2
  43. package/dist/store/hunchStore.js +23 -6
  44. package/dist/store/stateBinding.js +111 -42
  45. package/dist/wiki/wiki.d.ts +7 -0
  46. package/dist/wiki/wiki.js +19 -8
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -1581,6 +1581,7 @@ export declare const SCHEMAS: {
1581
1581
  unknown: "unknown";
1582
1582
  merged: "merged";
1583
1583
  unmerged: "unmerged";
1584
+ "no-commits": "no-commits";
1584
1585
  }>;
1585
1586
  method: z.ZodNullable<z.ZodEnum<{
1586
1587
  ancestry: "ancestry";
@@ -23,6 +23,9 @@ export declare const MACHINE_LABEL: RegExp;
23
23
  * `.lock` suffix, leading/trailing `.` or `/`, and bounded length. Every real branch
24
24
  * from `for-each-ref` passes; a crafted record cannot smuggle an argument. */
25
25
  export declare function isSafeBranchName(name: string): boolean;
26
+ /** C0/C1 control characters, including newline and ESC: a stored string carrying one could
27
+ * forge extra lines or terminal escapes in output a human reads (a printed command). */
28
+ export declare const CONTROL_CHARS: RegExp;
26
29
  export declare const WorkspaceWorktreeSchema: z.ZodObject<{
27
30
  id: z.ZodString;
28
31
  path: z.ZodNullable<z.ZodString>;
@@ -35,13 +38,17 @@ export declare const WorkspaceWorktreeSchema: z.ZodObject<{
35
38
  last_commit_at: z.ZodNullable<z.ZodString>;
36
39
  }, z.core.$strict>;
37
40
  export type WorkspaceWorktree = z.infer<typeof WorkspaceWorktreeSchema>;
38
- export declare const MERGED_STATUSES: readonly ["merged", "unmerged", "unknown"];
41
+ /** `no-commits`: the branch head lies on the default branch's first-parent history, so the
42
+ * branch holds no commits of its own (freshly created, or fast-forwarded into the default
43
+ * branch). Ancestry alone would call it merged; it is never offered for deletion. */
44
+ export declare const MERGED_STATUSES: readonly ["merged", "unmerged", "no-commits", "unknown"];
39
45
  export declare const MERGED_METHODS: readonly ["ancestry", "squash", "rebase"];
40
46
  export declare const MergedVerdictSchema: z.ZodObject<{
41
47
  status: z.ZodEnum<{
42
48
  unknown: "unknown";
43
49
  merged: "merged";
44
50
  unmerged: "unmerged";
51
+ "no-commits": "no-commits";
45
52
  }>;
46
53
  method: z.ZodNullable<z.ZodEnum<{
47
54
  ancestry: "ancestry";
@@ -67,6 +74,7 @@ export declare const WorkspaceBranchSchema: z.ZodObject<{
67
74
  unknown: "unknown";
68
75
  merged: "merged";
69
76
  unmerged: "unmerged";
77
+ "no-commits": "no-commits";
70
78
  }>;
71
79
  method: z.ZodNullable<z.ZodEnum<{
72
80
  ancestry: "ancestry";
@@ -124,6 +132,7 @@ export declare const WorkspaceSchema: z.ZodObject<{
124
132
  unknown: "unknown";
125
133
  merged: "merged";
126
134
  unmerged: "unmerged";
135
+ "no-commits": "no-commits";
127
136
  }>;
128
137
  method: z.ZodNullable<z.ZodEnum<{
129
138
  ancestry: "ancestry";
@@ -216,7 +225,20 @@ export interface PruneStep {
216
225
  } | null;
217
226
  commands: string[];
218
227
  why: string;
228
+ /** How the merge was proven; squash and rebase merges are invisible to `git branch -d`. */
229
+ method: MergedVerdict["method"];
230
+ /** Ignored files in the worktree that `git worktree remove` deletes without asking (local
231
+ * plan only, read live; never stored). `shown` is bounded, `total` counts all entries. */
232
+ ignored?: {
233
+ shown: string[];
234
+ total: number;
235
+ };
219
236
  }
237
+ /** POSIX shell quoting for one token of a PRINTED command (never executed through a shell
238
+ * here: execution uses argv arrays). Tokens made only of characters no shell treats
239
+ * specially stay bare; anything else is single-quoted with `'` escaped as `'\''`, which is
240
+ * also valid in Git Bash for Windows paths. */
241
+ export declare function shellQuote(token: string): string;
220
242
  export interface PrunePlan {
221
243
  /** Executable on this machine (live record). */
222
244
  local: PruneStep[];
@@ -43,7 +43,12 @@ const UpstreamName = z.string().max(320).refine((v) => {
43
43
  const slash = v.indexOf("/");
44
44
  return slash > 0 && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(v.slice(0, slash)) && isSafeBranchName(v.slice(slash + 1));
45
45
  }, { message: "upstream must be <remote>/<branch>" });
46
- const credentialFree = (label, max) => z.string().min(1).max(max).refine(isCredentialFreeValue, { message: `${label} must not carry credential material` });
46
+ /** C0/C1 control characters, including newline and ESC: a stored string carrying one could
47
+ * forge extra lines or terminal escapes in output a human reads (a printed command). */
48
+ export const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/;
49
+ const credentialFree = (label, max) => z.string().min(1).max(max)
50
+ .refine((v) => !CONTROL_CHARS.test(v), { message: `${label} must not contain control characters or newlines` })
51
+ .refine(isCredentialFreeValue, { message: `${label} must not carry credential material` });
47
52
  export const WorkspaceWorktreeSchema = z.object({
48
53
  /** Stable, path-free handle (hash of the path) so branches can point at a worktree in
49
54
  * `branches` publish mode, where the path itself is omitted. */
@@ -58,12 +63,17 @@ export const WorkspaceWorktreeSchema = z.object({
58
63
  prunable: z.boolean(),
59
64
  last_commit_at: z.string().regex(ISO).nullable(),
60
65
  }).strict();
61
- export const MERGED_STATUSES = ["merged", "unmerged", "unknown"];
66
+ /** `no-commits`: the branch head lies on the default branch's first-parent history, so the
67
+ * branch holds no commits of its own (freshly created, or fast-forwarded into the default
68
+ * branch). Ancestry alone would call it merged; it is never offered for deletion. */
69
+ export const MERGED_STATUSES = ["merged", "unmerged", "no-commits", "unknown"];
62
70
  export const MERGED_METHODS = ["ancestry", "squash", "rebase"];
63
71
  export const MergedVerdictSchema = z.object({
64
72
  status: z.enum(MERGED_STATUSES),
65
73
  method: z.enum(MERGED_METHODS).nullable(),
66
- evidence: z.array(z.string().max(256).refine(isCredentialFreeValue, { message: "verdict evidence must not carry credential material" })).max(8),
74
+ evidence: z.array(z.string().max(256)
75
+ .refine((v) => !CONTROL_CHARS.test(v), { message: "verdict evidence must not contain control characters or newlines" })
76
+ .refine(isCredentialFreeValue, { message: "verdict evidence must not carry credential material" })).max(8),
67
77
  /** The pull request that landed a merged branch, read from the LOCAL merge / squash commit
68
78
  * subject ("Merge pull request #N from …", "… (#N)") — never fetched from a forge. */
69
79
  pr: z.number().int().min(1).max(100_000_000).optional(),
@@ -218,6 +228,8 @@ export function recommendAction(row, opts = {}) {
218
228
  const dirty = row.dirty_on.length ? `; dirty worktree on ${row.dirty_on.join(", ")}` : "";
219
229
  if (row.merged.status === "unknown")
220
230
  return `review: merge state unknown${dirty}${suffix}`;
231
+ if (row.merged.status === "no-commits")
232
+ return `keep: no commits of its own${dirty}${suffix}`;
221
233
  if (row.upstream === null || row.upstream_gone) {
222
234
  const idle = daysIdle(row.last_commit_at, now);
223
235
  const why = row.upstream_gone ? "upstream deleted, unmerged work" : "unpushed";
@@ -233,6 +245,7 @@ export function recommendAction(row, opts = {}) {
233
245
  function bestVerdict(verdicts) {
234
246
  return verdicts.find((v) => v.status === "merged")
235
247
  ?? verdicts.find((v) => v.status === "unmerged")
248
+ ?? verdicts.find((v) => v.status === "no-commits")
236
249
  ?? verdicts[0]
237
250
  ?? { status: "unknown", method: null, evidence: [] };
238
251
  }
@@ -283,6 +296,15 @@ export function ago(iso, now = new Date()) {
283
296
  return `${h}h ago`;
284
297
  return `${Math.floor(h / 24)}d ago`;
285
298
  }
299
+ /** POSIX shell quoting for one token of a PRINTED command (never executed through a shell
300
+ * here: execution uses argv arrays). Tokens made only of characters no shell treats
301
+ * specially stay bare; anything else is single-quoted with `'` escaped as `'\''`, which is
302
+ * also valid in Git Bash for Windows paths. */
303
+ export function shellQuote(token) {
304
+ if (/^[A-Za-z0-9_\-./:@+,]+$/.test(token))
305
+ return token;
306
+ return `'${token.replace(/'/g, `'\\''`)}'`;
307
+ }
286
308
  function pruneStepsFor(record, opts) {
287
309
  const steps = [];
288
310
  const skipped = [];
@@ -296,11 +318,12 @@ function pruneStepsFor(record, opts) {
296
318
  }
297
319
  const commands = [];
298
320
  if (wt)
299
- commands.push(`git worktree remove -- ${wt.path ?? "<its worktree>"}`);
300
- commands.push(`git branch -d -- ${b.name}`);
321
+ commands.push(`git worktree remove -- ${wt.path === null ? "<its worktree>" : shellQuote(wt.path)}`);
322
+ commands.push(`git branch -d -- ${shellQuote(b.name)}`);
301
323
  steps.push({
302
324
  branch: b.name, head: b.head, worktree: wt ? { id: wt.id, path: wt.path } : null, commands,
303
325
  why: `${b.merged.method}${b.merged.pr ? ` (PR #${b.merged.pr})` : ""}: ${b.merged.evidence[0] ?? ""}`,
326
+ method: b.merged.method,
304
327
  });
305
328
  }
306
329
  return { steps, skipped };
@@ -310,8 +333,9 @@ function pruneStepsFor(record, opts) {
310
333
  export function pruneRefusal(b, wt) {
311
334
  if (b.is_default)
312
335
  return "default branch";
313
- if (b.merged.status !== "merged")
314
- return b.merged.status === "unknown" ? "merge state unknown" : "not merged";
336
+ if (b.merged.status !== "merged") {
337
+ return b.merged.status === "unknown" ? "merge state unknown" : b.merged.status === "no-commits" ? "no commits of its own" : "not merged";
338
+ }
315
339
  if (wt?.is_main)
316
340
  return "checked out in the main worktree (switch away first)";
317
341
  if (wt?.locked)
@@ -35,6 +35,40 @@ export interface DiffAnalysis {
35
35
  * the same (new-path) key as perFile. */
36
36
  addedLinesByFile: Map<string, string[]>;
37
37
  }
38
+ /** Undo git's C-style path quoting. Even with core.quotePath=false, git quotes a
39
+ * path holding `"`, `\`, or a control character (`"src/a\"b.ts"`); with the
40
+ * default quotePath it also octal-escapes every byte above 0x7F. Octal escapes
41
+ * are raw bytes, so the unescaped byte string is decoded as UTF-8. An unquoted
42
+ * or malformed value is returned unchanged. */
43
+ export declare function unquoteGitPath(p: string): string;
44
+ /** The final line a budget-capped diff ends with (see SYNTHESIS_DIFF_BUDGET in git.ts). */
45
+ export declare const DIFF_TRUNCATED_LINE = "\u2026(diff truncated)\u2026";
46
+ /** Does this diff end with the truncation marker (i.e. is it a prefix of the change)? */
47
+ export declare function isTruncatedDiff(diff: string): boolean;
48
+ /** The (new-side) path of every file block in a unified diff, in order — including
49
+ * blocks with no hunks (binary, mode-only). Paths use the same keys as
50
+ * DiffAnalysis.addedLinesByFile. */
51
+ export declare function diffBlockFiles(diff: string): string[];
52
+ /** How complete a gate's diff is (git.ts GateDiff satisfies this shape). */
53
+ export interface DiffStatus {
54
+ /** Why the diff as a whole cannot be trusted as complete. */
55
+ incomplete?: string;
56
+ /** The diff is a byte prefix of the change, so its last block may be cut. */
57
+ truncated?: boolean;
58
+ /** Files whose content could not be read into the diff. */
59
+ unreadFiles?: string[];
60
+ }
61
+ export interface DiffContentGaps {
62
+ /** True when this file's added lines may be missing from the diff. */
63
+ missing(file: string): boolean;
64
+ /** A human-readable reason for the given missing files. */
65
+ reason(files: string[]): string;
66
+ }
67
+ /** Which files a content check cannot trust "no added lines" for. Null when the
68
+ * diff is complete, so callers keep the exact small-diff behavior. A truncated
69
+ * diff (ends with DIFF_TRUNCATED_LINE) covers only the blocks before its last,
70
+ * possibly cut, block; an `incomplete` status covers only the blocks present. */
71
+ export declare function diffContentGaps(diff: string, status?: DiffStatus): DiffContentGaps | null;
38
72
  export declare function analyzeDiff(diff: string): DiffAnalysis;
39
73
  /** A compact human-readable summary of a DiffAnalysis (used in decision text). */
40
74
  export declare function summarizeDiff(a: DiffAnalysis): string;
@@ -59,6 +59,148 @@ function importOf(line) {
59
59
  function stripAB(p) {
60
60
  return p.replace(/^[ab]\//, "");
61
61
  }
62
+ const C_ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, "\\": 92 };
63
+ /** Undo git's C-style path quoting. Even with core.quotePath=false, git quotes a
64
+ * path holding `"`, `\`, or a control character (`"src/a\"b.ts"`); with the
65
+ * default quotePath it also octal-escapes every byte above 0x7F. Octal escapes
66
+ * are raw bytes, so the unescaped byte string is decoded as UTF-8. An unquoted
67
+ * or malformed value is returned unchanged. */
68
+ export function unquoteGitPath(p) {
69
+ if (p.length < 2 || !p.startsWith('"') || !p.endsWith('"'))
70
+ return p;
71
+ const body = p.slice(1, -1);
72
+ const bytes = [];
73
+ for (let i = 0; i < body.length; i++) {
74
+ const ch = body[i];
75
+ if (ch !== "\\") {
76
+ for (const b of Buffer.from(ch, "utf8"))
77
+ bytes.push(b);
78
+ continue;
79
+ }
80
+ const next = body[i + 1];
81
+ if (next === undefined)
82
+ return p;
83
+ if (/[0-7]/.test(next)) {
84
+ const oct = /^[0-7]{1,3}/.exec(body.slice(i + 1))[0];
85
+ bytes.push(parseInt(oct, 8) & 0xff);
86
+ i += oct.length;
87
+ continue;
88
+ }
89
+ const code = C_ESCAPES[next];
90
+ if (code === undefined)
91
+ return p;
92
+ bytes.push(code);
93
+ i += 1;
94
+ }
95
+ return Buffer.from(bytes).toString("utf8");
96
+ }
97
+ /** A `---`/`+++` header path: strip git's trailing tab (added after names with
98
+ * spaces), unquote, then drop the a/ b/ prefix. */
99
+ function headerPath(raw) {
100
+ const value = raw.replace(/\r$/, "").replace(/\t$/, "").trim();
101
+ return value.startsWith('"') ? unquoteGitPath(value) : value;
102
+ }
103
+ /** The final line a budget-capped diff ends with (see SYNTHESIS_DIFF_BUDGET in git.ts). */
104
+ export const DIFF_TRUNCATED_LINE = "…(diff truncated)…";
105
+ /** Does this diff end with the truncation marker (i.e. is it a prefix of the change)? */
106
+ export function isTruncatedDiff(diff) {
107
+ return diff === DIFF_TRUNCATED_LINE || diff.endsWith(`\n${DIFF_TRUNCATED_LINE}`);
108
+ }
109
+ /** The (new-side) path of every file block in a unified diff, in order — including
110
+ * blocks with no hunks (binary, mode-only). Paths use the same keys as
111
+ * DiffAnalysis.addedLinesByFile. */
112
+ export function diffBlockFiles(diff) {
113
+ const out = [];
114
+ let cur = null;
115
+ const flush = () => { if (cur?.path)
116
+ out.push(cur.path); };
117
+ for (const raw of diff.split("\n")) {
118
+ if (raw.startsWith("diff --git ")) {
119
+ flush();
120
+ cur = { path: gitHeaderNewPath(raw.slice("diff --git ".length)), inHunk: false };
121
+ continue;
122
+ }
123
+ if (!cur)
124
+ continue;
125
+ if (raw.startsWith("@@")) {
126
+ cur.inHunk = true;
127
+ continue;
128
+ }
129
+ if (cur.inHunk)
130
+ continue;
131
+ if (raw.startsWith("rename to ") || raw.startsWith("copy to ")) {
132
+ cur.path = headerPath(raw.slice(raw.indexOf(" to ") + 4));
133
+ }
134
+ else if (raw.startsWith("+++ ")) {
135
+ const p = headerPath(raw.slice(4));
136
+ if (p !== "/dev/null")
137
+ cur.path = stripAB(p);
138
+ }
139
+ else if (raw.startsWith("--- ")) {
140
+ const p = headerPath(raw.slice(4));
141
+ if (p !== "/dev/null")
142
+ cur.path = stripAB(p);
143
+ }
144
+ }
145
+ flush();
146
+ return out;
147
+ }
148
+ /** Which files a content check cannot trust "no added lines" for. Null when the
149
+ * diff is complete, so callers keep the exact small-diff behavior. A truncated
150
+ * diff (ends with DIFF_TRUNCATED_LINE) covers only the blocks before its last,
151
+ * possibly cut, block; an `incomplete` status covers only the blocks present. */
152
+ export function diffContentGaps(diff, status) {
153
+ const truncated = !!status?.truncated || isTruncatedDiff(diff);
154
+ const unread = new Set(status?.unreadFiles ?? []);
155
+ const wholeReason = status?.incomplete ?? (truncated ? "the diff was truncated before this file's changes" : undefined);
156
+ if (!wholeReason && !unread.size)
157
+ return null;
158
+ let covered = null;
159
+ if (wholeReason) {
160
+ const blocks = diffBlockFiles(diff);
161
+ if (truncated)
162
+ blocks.pop();
163
+ covered = new Set(blocks);
164
+ }
165
+ const outsideDiff = (f) => covered !== null && !covered.has(f);
166
+ return {
167
+ missing: (f) => unread.has(f) || outsideDiff(f),
168
+ reason: (files) => {
169
+ const parts = [];
170
+ if (files.some(outsideDiff))
171
+ parts.push(wholeReason);
172
+ if (files.some((f) => unread.has(f)))
173
+ parts.push("file content could not be read");
174
+ return parts.join("; ");
175
+ },
176
+ };
177
+ }
178
+ /** Best-effort new path from a `diff --git <a> <b>` line, used only when a block
179
+ * has no ---/+++ header (binary or mode-only). Unambiguous when both sides are
180
+ * the same path, which is the case for every non-rename block. */
181
+ function gitHeaderNewPath(rest) {
182
+ const line = rest.replace(/\r$/, "");
183
+ if (line.startsWith('"')) {
184
+ const close = /^"(?:[^"\\]|\\.)*"/.exec(line);
185
+ if (!close)
186
+ return "";
187
+ const second = line.slice(close[0].length + 1);
188
+ return stripAB(unquoteGitPath(second.startsWith('"') ? second : second.trim()));
189
+ }
190
+ if (line.endsWith('"')) {
191
+ const open = line.lastIndexOf(' "');
192
+ return open >= 0 ? stripAB(unquoteGitPath(line.slice(open + 1))) : "";
193
+ }
194
+ // "a/P b/P": both halves have equal length when old and new path agree.
195
+ if ((line.length - 1) % 2 === 0) {
196
+ const half = (line.length - 1) / 2;
197
+ const a = line.slice(0, half), b = line.slice(half + 1);
198
+ if (stripAB(a) === stripAB(b))
199
+ return stripAB(b);
200
+ }
201
+ const lastB = line.lastIndexOf(" b/");
202
+ return lastB >= 0 ? line.slice(lastB + 3) : "";
203
+ }
62
204
  export function analyzeDiff(diff) {
63
205
  const filesAdded = new Set();
64
206
  const filesDeleted = new Set();
@@ -106,24 +248,24 @@ export function analyzeDiff(diff) {
106
248
  curDeleted = true;
107
249
  }
108
250
  else if (raw.startsWith("rename from ")) {
109
- renameFrom = raw.slice("rename from ".length).trim();
251
+ renameFrom = headerPath(raw.slice("rename from ".length));
110
252
  }
111
253
  else if (raw.startsWith("copy from ")) {
112
- renameFrom = raw.slice("copy from ".length).trim();
254
+ renameFrom = headerPath(raw.slice("copy from ".length));
113
255
  }
114
256
  else if (raw.startsWith("rename to ") || raw.startsWith("copy to ")) {
115
- const to = raw.slice(raw.indexOf(" to ") + 4).trim();
257
+ const to = headerPath(raw.slice(raw.indexOf(" to ") + 4));
116
258
  curFile = to;
117
259
  if (isSubstantive(to))
118
260
  filesRenamed.push({ from: renameFrom, to });
119
261
  }
120
262
  else if (raw.startsWith("--- ")) {
121
- const p = raw.slice(4).trim();
263
+ const p = headerPath(raw.slice(4));
122
264
  if (p !== "/dev/null")
123
265
  curFile = stripAB(p); // old path (may be replaced by +++)
124
266
  }
125
267
  else if (raw.startsWith("+++ ")) {
126
- const p = raw.slice(4).trim();
268
+ const p = headerPath(raw.slice(4));
127
269
  if (p !== "/dev/null")
128
270
  curFile = stripAB(p); // new path preferred
129
271
  if (isSubstantive(curFile)) {
@@ -143,6 +143,9 @@ export declare function isolatedHeadSha(cwd: string): string;
143
143
  * without deleting it locally. `--ignore-unmatch` makes an already-untracked path a
144
144
  * no-op rather than an error; best-effort (a non-repo dir just no-ops). */
145
145
  export declare function gitUntrackCached(cwd: string, paths: string[]): void;
146
+ /** Tracked files under the given pathspecs (repo-relative, POSIX). Best-effort:
147
+ * `[]` when not a repository or nothing is tracked there. */
148
+ export declare function gitTrackedPaths(cwd: string, paths: string[]): string[];
146
149
  /** Resolve any commit-ish (short sha / HEAD / branch) to a canonical full sha.
147
150
  * Returns the input unchanged if it can't be resolved (e.g. not a git repo). */
148
151
  export declare function revParse(ref: string, cwd: string): string;
@@ -222,9 +225,28 @@ export type CommitRepairStatus = "orphaned" | "current" | "unresolvable" | "unkn
222
225
  * be treated as repair-eligible; collapsing "false" and "error" into one
223
226
  * boolean is exactly the bug this type exists to prevent. */
224
227
  export declare function commitRepairStatus(commit: string, ref: string, cwd: string): CommitRepairStatus;
225
- /** The unified diff for a commit, truncated to keep synthesis prompts bounded.
228
+ /** Byte budget for a diff embedded in a SYNTHESIS prompt. Gates never use it: a
229
+ * constraint check over a prefix of a change would read every file past the
230
+ * cutoff as "no added lines" and pass it. */
231
+ export declare const SYNTHESIS_DIFF_BUDGET = 60000;
232
+ /** The diff a GATE evaluates (`hunch check`, the CI Constraint Guard, merge verdict,
233
+ * veto, PR impact). Complete — no byte budget and no noise exclusion — or explicitly
234
+ * marked incomplete, so content-matched constraints fail closed instead of reading a
235
+ * missing hunk as "nothing added". */
236
+ export interface GateDiff {
237
+ diff: string;
238
+ /** Why the diff cannot be trusted as complete (git failed, or its output exceeded
239
+ * the capture ceiling). Every file without a diff block is then unevaluable. */
240
+ incomplete?: string;
241
+ /** Files whose content could not be read into the diff; unevaluable individually. */
242
+ unreadFiles?: string[];
243
+ }
244
+ /** Complete gating diff of one commit (see GateDiff). */
245
+ export declare function commitGateDiff(sha: string, cwd: string): GateDiff;
246
+ /** The unified diff for a commit, truncated to keep SYNTHESIS prompts bounded.
226
247
  * Machine-generated noise (see DIFF_NOISE) is excluded so the model spends its
227
- * budget on code that encodes intent, not on regenerated lockfiles/build output. */
248
+ * budget on code that encodes intent, not on regenerated lockfiles/build output.
249
+ * Not for gating: use commitGateDiff. */
228
250
  export declare function commitDiff(sha: string, cwd: string, maxBytes?: number): string;
229
251
  /** Number of commits touching a file in the last `days` (churn). */
230
252
  export declare function fileChurn(file: string, cwd: string, days?: number): number;
@@ -278,18 +300,25 @@ export declare function rangeFiles(base: string, cwd: string, head?: string): st
278
300
  /** Commit subjects on `head` since `base` (2-dot: commits added by the task),
279
301
  * oldest-first, for distilling a runbook's ordered steps (roadmap #5). */
280
302
  export declare function rangeSubjects(base: string, cwd: string, head?: string, max?: number): string[];
281
- /** The PR's unified diff vs `base` (3-dot), for the Regression Guard's structural
282
- * analysis. Same noise-exclusion + truncation budget as commit/staged diffs. */
303
+ /** Complete gating diff of a PR vs `base` (3-dot: the CI Constraint Guard's surface). */
304
+ export declare function rangeGateDiff(base: string, cwd: string, head?: string): GateDiff;
305
+ /** The PR's unified diff vs `base` (3-dot), noise-excluded and capped at the
306
+ * synthesis budget. Bounded-prompt use only; gates use rangeGateDiff. */
283
307
  export declare function rangeDiff(base: string, cwd: string, head?: string, maxBytes?: number): string;
284
- /** Unified diff of the staged changes (for the Regression Guard's structural
285
- * analysis). Excludes machine-generated noise and truncates at the SAME budget as
286
- * commitDiff, so the staged and `--commit` guard paths can't diverge on big diffs. */
308
+ /** Complete gating diff of the staged changes (pre-commit `hunch check`). */
309
+ export declare function stagedGateDiff(cwd: string): GateDiff;
310
+ /** Unified diff of the staged changes, noise-excluded and capped at the synthesis
311
+ * budget. Bounded-prompt use only; gates use stagedGateDiff. */
287
312
  export declare function stagedDiff(cwd: string, maxBytes?: number): string;
288
- /** Unified diff of the complete local working tree vs HEAD. Git's normal diff
313
+ /** Complete gating diff of the local working tree vs HEAD. Git's normal diff
289
314
  * includes both staged and unstaged tracked edits; untracked text files are
290
- * appended as synthetic additions so guards can also see their added symbols.
291
- * Binary/unreadable files remain in workingFiles (scope checks still apply) but
292
- * intentionally contribute no synthetic content to regression analysis. */
315
+ * appended as synthetic additions so guards can also see their added lines.
316
+ * Binary files and non-regular paths (symlinks) contribute no content, as in a
317
+ * git diff; an untracked regular file that cannot be read is listed in
318
+ * `unreadFiles`, so content checks over it fail closed. */
319
+ export declare function workingGateDiff(cwd: string): GateDiff;
320
+ /** Working-tree diff vs HEAD capped at the synthesis budget. Bounded-prompt use
321
+ * only; gates and rule verification use workingGateDiff. */
293
322
  export declare function workingDiff(cwd: string, maxBytes?: number): string;
294
323
  /** Resolve a time-travel ref (commit / tag / branch / HEAD~n) to the ISO author-
295
324
  * date of that commit — the instant valid-time windows are filtered against.