@davesheffer/hunch 1.38.1 → 1.39.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.
- package/dist/cli/index.js +355 -55
- package/dist/cli/integrations.js +10 -0
- package/dist/cli/serve.js +1 -0
- package/dist/client/readOrCompute.d.ts +77 -0
- package/dist/client/readOrCompute.js +85 -0
- package/dist/client/state.d.ts +1 -0
- package/dist/client/state.js +1 -0
- package/dist/constitution/g2.d.ts +1 -0
- package/dist/constitution/service.js +8 -0
- package/dist/constitution/sourceMutation.js +23 -18
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +48 -5
- package/dist/core/changeProof.js +5 -1
- package/dist/core/checkreport.d.ts +7 -0
- package/dist/core/checkreport.js +20 -3
- package/dist/core/compare.js +3 -2
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +13 -0
- package/dist/core/machine.d.ts +20 -0
- package/dist/core/machine.js +101 -0
- package/dist/core/taskReportEvidence.js +6 -6
- package/dist/core/types.d.ts +67 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +256 -0
- package/dist/core/workspace.js +359 -0
- package/dist/extractors/diff.d.ts +34 -0
- package/dist/extractors/diff.js +147 -5
- package/dist/extractors/git.d.ts +40 -11
- package/dist/extractors/git.js +147 -43
- package/dist/extractors/helm.d.ts +17 -28
- package/dist/extractors/helm.js +12 -12
- package/dist/extractors/indexer.js +171 -7
- package/dist/extractors/k8sManifest.d.ts +59 -0
- package/dist/extractors/k8sManifest.js +507 -0
- package/dist/extractors/workspaces.d.ts +28 -0
- package/dist/extractors/workspaces.js +427 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.d.ts +27 -2
- package/dist/integrations/gitignore.js +103 -17
- package/dist/integrations/hooks.d.ts +63 -7
- package/dist/integrations/hooks.js +350 -38
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +93 -0
- package/dist/integrations/workspaceLedger.js +307 -0
- package/dist/mcp/server.js +59 -5
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +107 -92
- package/dist/serve/mcpHttp.d.ts +27 -0
- package/dist/serve/mcpHttp.js +95 -0
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +83 -35
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace ledger — one record per MACHINE per repository describing that machine's
|
|
3
|
+
* git worktrees and local branches, with deterministic merged verdicts
|
|
4
|
+
* (docs/workspace-ledger.md). A LEAF module (zod + ids + provenance only) so types.ts
|
|
5
|
+
* can register the kind without a cycle, like stateRecords.ts.
|
|
6
|
+
*
|
|
7
|
+
* Security posture, in code: the schema is `.strict()` with bounded lengths, every
|
|
8
|
+
* branch name must be a git-valid ref component, every free-text field passes the
|
|
9
|
+
* credential filter, and NOTHING here reads a record back as authority — the
|
|
10
|
+
* aggregation below produces DISPLAY rows and a recommended action; `prune --apply`
|
|
11
|
+
* (Phase 3) re-snapshots live git and never acts on a stored record.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { shortHash } from "./ids.js";
|
|
15
|
+
import { ProvenanceSchema, isCredentialFreeValue } from "./provenance.js";
|
|
16
|
+
export const WORKSPACE_SCHEMA_VERSION = "hunch.workspace/1";
|
|
17
|
+
/** Record bounds. The extractor keeps the most recently committed entries and says so in
|
|
18
|
+
* provenance, so a huge repository degrades to a truncated record, never to a crash. */
|
|
19
|
+
export const MAX_WORKTREES = 512;
|
|
20
|
+
export const MAX_BRANCHES = 4096;
|
|
21
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
22
|
+
const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
23
|
+
export const MACHINE_ID = /^mac_[0-9a-f]{32}$/;
|
|
24
|
+
export const MACHINE_LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
25
|
+
/** A branch name git would accept (`git check-ref-format --branch`), fail-closed: no
|
|
26
|
+
* leading `-` (flag smuggling), no control/whitespace characters, no `..`, `@{`,
|
|
27
|
+
* `.lock` suffix, leading/trailing `.` or `/`, and bounded length. Every real branch
|
|
28
|
+
* from `for-each-ref` passes; a crafted record cannot smuggle an argument. */
|
|
29
|
+
export function isSafeBranchName(name) {
|
|
30
|
+
if (!name || name.length > 256)
|
|
31
|
+
return false;
|
|
32
|
+
if (name.startsWith("-") || name.startsWith("/") || name.endsWith("/") || name.startsWith(".") || name.endsWith("."))
|
|
33
|
+
return false;
|
|
34
|
+
if (name.includes("..") || name.includes("@{") || name.includes("//") || name.endsWith(".lock") || name === "@")
|
|
35
|
+
return false;
|
|
36
|
+
if (/[\s~^:?*[\\\x00-\x1f\x7f]/.test(name))
|
|
37
|
+
return false;
|
|
38
|
+
return !name.split("/").some((part) => part.startsWith(".") || part.endsWith(".lock"));
|
|
39
|
+
}
|
|
40
|
+
const BranchName = z.string().refine(isSafeBranchName, { message: "branch name must be a git-valid, flag-free ref" });
|
|
41
|
+
/** `origin/feature` — a remote-tracking short ref: remote name plus a safe branch name. */
|
|
42
|
+
const UpstreamName = z.string().max(320).refine((v) => {
|
|
43
|
+
const slash = v.indexOf("/");
|
|
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
|
+
}, { message: "upstream must be <remote>/<branch>" });
|
|
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` });
|
|
52
|
+
export const WorkspaceWorktreeSchema = z.object({
|
|
53
|
+
/** Stable, path-free handle (hash of the path) so branches can point at a worktree in
|
|
54
|
+
* `branches` publish mode, where the path itself is omitted. */
|
|
55
|
+
id: z.string().regex(/^wt_[0-9a-f]{8}$/),
|
|
56
|
+
path: credentialFree("worktree path", 1024).nullable(),
|
|
57
|
+
branch: BranchName.nullable(),
|
|
58
|
+
head: z.string().regex(SHA),
|
|
59
|
+
is_main: z.boolean(),
|
|
60
|
+
/** null = could not be determined (the path no longer exists). */
|
|
61
|
+
dirty: z.boolean().nullable(),
|
|
62
|
+
locked: z.boolean(),
|
|
63
|
+
prunable: z.boolean(),
|
|
64
|
+
last_commit_at: z.string().regex(ISO).nullable(),
|
|
65
|
+
}).strict();
|
|
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"];
|
|
70
|
+
export const MERGED_METHODS = ["ancestry", "squash", "rebase"];
|
|
71
|
+
export const MergedVerdictSchema = z.object({
|
|
72
|
+
status: z.enum(MERGED_STATUSES),
|
|
73
|
+
method: z.enum(MERGED_METHODS).nullable(),
|
|
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),
|
|
77
|
+
/** The pull request that landed a merged branch, read from the LOCAL merge / squash commit
|
|
78
|
+
* subject ("Merge pull request #N from …", "… (#N)") — never fetched from a forge. */
|
|
79
|
+
pr: z.number().int().min(1).max(100_000_000).optional(),
|
|
80
|
+
}).strict().superRefine((v, ctx) => {
|
|
81
|
+
if ((v.status === "merged") !== (v.method !== null)) {
|
|
82
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "a merged verdict names its method; any other verdict has none" });
|
|
83
|
+
}
|
|
84
|
+
if (v.pr !== undefined && v.status !== "merged") {
|
|
85
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["pr"], message: "only a merged verdict can name a pull request" });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
export const WorkspaceBranchSchema = z.object({
|
|
89
|
+
name: BranchName,
|
|
90
|
+
head: z.string().regex(SHA),
|
|
91
|
+
is_default: z.boolean(),
|
|
92
|
+
upstream: UpstreamName.nullable(),
|
|
93
|
+
upstream_gone: z.boolean(),
|
|
94
|
+
ahead: z.number().int().min(0).max(1_000_000).nullable(),
|
|
95
|
+
behind: z.number().int().min(0).max(1_000_000).nullable(),
|
|
96
|
+
last_commit_at: z.string().regex(ISO).nullable(),
|
|
97
|
+
/** The worktree (by id) checked out on this branch, if any. */
|
|
98
|
+
worktree: z.string().regex(/^wt_[0-9a-f]{8}$/).nullable(),
|
|
99
|
+
merged: MergedVerdictSchema,
|
|
100
|
+
}).strict();
|
|
101
|
+
export const WorkspaceSchema = z.object({
|
|
102
|
+
schema: z.literal(WORKSPACE_SCHEMA_VERSION),
|
|
103
|
+
id: z.string().regex(/^ws_[0-9a-f]{12}$/),
|
|
104
|
+
machine: z.object({
|
|
105
|
+
id: z.string().regex(MACHINE_ID),
|
|
106
|
+
label: z.string().regex(MACHINE_LABEL),
|
|
107
|
+
platform: z.string().regex(/^[a-z0-9]{1,16}$/),
|
|
108
|
+
}).strict(),
|
|
109
|
+
/** The existing privacy-safe repository label (`stableRepositoryName`): a digest of the
|
|
110
|
+
* canonical fetch remote, never a URL or a path. */
|
|
111
|
+
repository: credentialFree("repository label", 256),
|
|
112
|
+
publish: z.enum(["full", "branches"]),
|
|
113
|
+
observed_at: z.string().regex(ISO),
|
|
114
|
+
fetched_at: z.string().regex(ISO).nullable(),
|
|
115
|
+
default_branch: z.object({ name: BranchName, ref: UpstreamName.or(BranchName), head: z.string().regex(SHA) }).strict().nullable(),
|
|
116
|
+
worktrees: z.array(WorkspaceWorktreeSchema).max(MAX_WORKTREES),
|
|
117
|
+
branches: z.array(WorkspaceBranchSchema).max(MAX_BRANCHES),
|
|
118
|
+
provenance: ProvenanceSchema,
|
|
119
|
+
}).strict().superRefine((record, ctx) => {
|
|
120
|
+
if (record.id !== workspaceId(record.machine.id)) {
|
|
121
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["id"], message: "workspace id must derive from the machine id" });
|
|
122
|
+
}
|
|
123
|
+
if (record.publish === "branches" && record.worktrees.some((w) => w.path !== null)) {
|
|
124
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["worktrees"], message: "branches publish mode carries no worktree paths" });
|
|
125
|
+
}
|
|
126
|
+
const worktreeIds = new Set(record.worktrees.map((w) => w.id));
|
|
127
|
+
if (worktreeIds.size !== record.worktrees.length) {
|
|
128
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["worktrees"], message: "worktree ids must be unique" });
|
|
129
|
+
}
|
|
130
|
+
const names = new Set();
|
|
131
|
+
for (const [index, branch] of record.branches.entries()) {
|
|
132
|
+
if (names.has(branch.name))
|
|
133
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["branches", index], message: "branch names must be unique" });
|
|
134
|
+
names.add(branch.name);
|
|
135
|
+
if (branch.worktree !== null && !worktreeIds.has(branch.worktree)) {
|
|
136
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["branches", index, "worktree"], message: "branch points at a worktree this record does not carry" });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (record.provenance.evidence.length > 64 || record.provenance.evidence.some((e) => e.length > 512 || !isCredentialFreeValue(e))) {
|
|
140
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["provenance"], message: "workspace provenance must remain bounded and credential-free" });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
/** One record per machine: the id derives from the machine id, so a re-snapshot
|
|
144
|
+
* UPDATES the machine's record and two machines can never collide on a file. */
|
|
145
|
+
export function workspaceId(machineId) {
|
|
146
|
+
return "ws_" + machineId.replace(/^mac_/, "").slice(0, 12);
|
|
147
|
+
}
|
|
148
|
+
/** Path-free worktree handle. */
|
|
149
|
+
export function worktreeId(path) {
|
|
150
|
+
return "wt_" + shortHash(path, 8);
|
|
151
|
+
}
|
|
152
|
+
/** The same observation, published under `publish`: `branches` drops every worktree path
|
|
153
|
+
* (the default), `full` keeps them. Pure, so a caller that already took a live snapshot
|
|
154
|
+
* (paths included, for its own display) can publish it without re-running git. */
|
|
155
|
+
export function withPublishMode(record, publish) {
|
|
156
|
+
if (record.publish === publish)
|
|
157
|
+
return record;
|
|
158
|
+
return WorkspaceSchema.parse({
|
|
159
|
+
...record,
|
|
160
|
+
publish,
|
|
161
|
+
worktrees: record.worktrees.map((w) => ({ ...w, path: publish === "full" ? w.path : null })),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** True when two snapshots of the same machine describe the same workspace, ignoring the
|
|
165
|
+
* observation stamps — so an idle machine's hook does not commit a new record per
|
|
166
|
+
* checkout. Provenance is constant per build and is compared too. */
|
|
167
|
+
export function sameWorkspaceContent(a, b) {
|
|
168
|
+
const strip = (w) => JSON.stringify({ ...w, observed_at: null, fetched_at: null });
|
|
169
|
+
return strip(a) === strip(b);
|
|
170
|
+
}
|
|
171
|
+
export const DEFAULT_STALE_AFTER_DAYS = 7;
|
|
172
|
+
export function isUnverified(record, opts = {}) {
|
|
173
|
+
const now = (opts.now ?? new Date()).getTime();
|
|
174
|
+
const days = opts.staleAfterDays ?? DEFAULT_STALE_AFTER_DAYS;
|
|
175
|
+
const observed = Date.parse(record.observed_at);
|
|
176
|
+
return !Number.isFinite(observed) || now - observed > days * 86_400_000;
|
|
177
|
+
}
|
|
178
|
+
/** Same machine id → the newest observation wins; a stale duplicate never shadows a fresh one. */
|
|
179
|
+
export function latestPerMachine(records) {
|
|
180
|
+
const byMachine = new Map();
|
|
181
|
+
for (const r of records) {
|
|
182
|
+
const prev = byMachine.get(r.machine.id);
|
|
183
|
+
if (!prev || Date.parse(r.observed_at) > Date.parse(prev.observed_at))
|
|
184
|
+
byMachine.set(r.machine.id, r);
|
|
185
|
+
}
|
|
186
|
+
return [...byMachine.values()].sort((a, b) => a.machine.label.localeCompare(b.machine.label));
|
|
187
|
+
}
|
|
188
|
+
export function worktreeRows(records, opts = {}) {
|
|
189
|
+
const rows = [];
|
|
190
|
+
for (const r of latestPerMachine(records)) {
|
|
191
|
+
const unverified = isUnverified(r, opts);
|
|
192
|
+
for (const w of r.worktrees) {
|
|
193
|
+
rows.push({
|
|
194
|
+
machine: r.machine.label, worktree_id: w.id, path: w.path, branch: w.branch, head: w.head,
|
|
195
|
+
dirty: w.dirty, locked: w.locked, prunable: w.prunable, last_commit_at: w.last_commit_at,
|
|
196
|
+
seen_at: r.observed_at, unverified,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return rows;
|
|
201
|
+
}
|
|
202
|
+
function daysIdle(iso, now) {
|
|
203
|
+
if (!iso)
|
|
204
|
+
return null;
|
|
205
|
+
const t = Date.parse(iso);
|
|
206
|
+
return Number.isFinite(t) ? Math.floor((now.getTime() - t) / 86_400_000) : null;
|
|
207
|
+
}
|
|
208
|
+
/** The recommendation rules from docs/workspace-ledger.md — deterministic text an agent
|
|
209
|
+
* or a human reads; nothing executes it. */
|
|
210
|
+
export function recommendAction(row, opts = {}) {
|
|
211
|
+
const now = opts.now ?? new Date();
|
|
212
|
+
const staleDays = opts.staleAfterDays ?? DEFAULT_STALE_AFTER_DAYS;
|
|
213
|
+
const suffix = row.unverified_on.length ? ` (unverified: ${row.unverified_on.join(", ")})` : "";
|
|
214
|
+
if (row.is_default)
|
|
215
|
+
return "keep: default branch";
|
|
216
|
+
if (row.heads.length > 1)
|
|
217
|
+
return `review: local heads differ across ${row.machines.join(", ")}${suffix}`;
|
|
218
|
+
if (row.merged.status === "merged") {
|
|
219
|
+
if (row.dirty_on.length)
|
|
220
|
+
return `keep: dirty worktree on ${row.dirty_on.join(", ")}${suffix}`;
|
|
221
|
+
const parts = [`delete local on ${row.machines.join(", ")}`];
|
|
222
|
+
if (row.worktree_on.length)
|
|
223
|
+
parts.push(`prune worktree on ${row.worktree_on.join(", ")}`);
|
|
224
|
+
return parts.join("; ") + suffix;
|
|
225
|
+
}
|
|
226
|
+
// Unmerged: a dirty worktree is not a reason to keep (the branch is kept anyway) but
|
|
227
|
+
// it is what the reader needs to know before touching the branch on that machine.
|
|
228
|
+
const dirty = row.dirty_on.length ? `; dirty worktree on ${row.dirty_on.join(", ")}` : "";
|
|
229
|
+
if (row.merged.status === "unknown")
|
|
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}`;
|
|
233
|
+
if (row.upstream === null || row.upstream_gone) {
|
|
234
|
+
const idle = daysIdle(row.last_commit_at, now);
|
|
235
|
+
const why = row.upstream_gone ? "upstream deleted, unmerged work" : "unpushed";
|
|
236
|
+
if (idle !== null && idle > staleDays)
|
|
237
|
+
return `review: ${why}, ${idle}d idle${dirty}${suffix}`;
|
|
238
|
+
return `keep: ${why}${dirty}${suffix}`;
|
|
239
|
+
}
|
|
240
|
+
return `keep${dirty}${suffix}`;
|
|
241
|
+
}
|
|
242
|
+
/** Merge verdicts across machines for one branch at one head: any machine that PROVED a
|
|
243
|
+
* merge (with evidence) wins over machines that could not tell; `unknown` never
|
|
244
|
+
* outranks `unmerged`, so an offline machine cannot mask real unmerged work. */
|
|
245
|
+
function bestVerdict(verdicts) {
|
|
246
|
+
return verdicts.find((v) => v.status === "merged")
|
|
247
|
+
?? verdicts.find((v) => v.status === "unmerged")
|
|
248
|
+
?? verdicts.find((v) => v.status === "no-commits")
|
|
249
|
+
?? verdicts[0]
|
|
250
|
+
?? { status: "unknown", method: null, evidence: [] };
|
|
251
|
+
}
|
|
252
|
+
export function branchRows(records, opts = {}) {
|
|
253
|
+
const latest = latestPerMachine(records);
|
|
254
|
+
const byName = new Map();
|
|
255
|
+
for (const record of latest) {
|
|
256
|
+
const unverified = isUnverified(record, opts);
|
|
257
|
+
for (const branch of record.branches) {
|
|
258
|
+
const list = byName.get(branch.name) ?? [];
|
|
259
|
+
list.push({ record, branch, unverified });
|
|
260
|
+
byName.set(branch.name, list);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const rows = [];
|
|
264
|
+
for (const [name, entries] of [...byName.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
265
|
+
const worktreeOf = (e) => e.branch.worktree ? e.record.worktrees.find((w) => w.id === e.branch.worktree) : undefined;
|
|
266
|
+
const withUpstream = entries.find((e) => e.branch.upstream !== null) ?? entries[0];
|
|
267
|
+
const partial = {
|
|
268
|
+
name,
|
|
269
|
+
machines: entries.map((e) => e.record.machine.label),
|
|
270
|
+
worktree_on: entries.filter((e) => worktreeOf(e)).map((e) => e.record.machine.label),
|
|
271
|
+
dirty_on: entries.filter((e) => worktreeOf(e)?.dirty === true).map((e) => e.record.machine.label),
|
|
272
|
+
heads: [...new Set(entries.map((e) => e.branch.head))],
|
|
273
|
+
is_default: entries.some((e) => e.branch.is_default),
|
|
274
|
+
upstream: withUpstream.branch.upstream,
|
|
275
|
+
upstream_gone: entries.some((e) => e.branch.upstream_gone),
|
|
276
|
+
ahead: withUpstream.branch.ahead,
|
|
277
|
+
behind: withUpstream.branch.behind,
|
|
278
|
+
last_commit_at: entries.map((e) => e.branch.last_commit_at).filter((d) => !!d).sort().at(-1) ?? null,
|
|
279
|
+
merged: bestVerdict(entries.map((e) => e.branch.merged)),
|
|
280
|
+
unverified_on: entries.filter((e) => e.unverified).map((e) => e.record.machine.label),
|
|
281
|
+
};
|
|
282
|
+
rows.push({ ...partial, action: recommendAction(partial, opts) });
|
|
283
|
+
}
|
|
284
|
+
return rows;
|
|
285
|
+
}
|
|
286
|
+
/** "2h ago" / "9d ago" for the SEEN column. */
|
|
287
|
+
export function ago(iso, now = new Date()) {
|
|
288
|
+
const ms = now.getTime() - Date.parse(iso);
|
|
289
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
290
|
+
return "just now";
|
|
291
|
+
const m = Math.floor(ms / 60_000);
|
|
292
|
+
if (m < 60)
|
|
293
|
+
return `${m}m ago`;
|
|
294
|
+
const h = Math.floor(m / 60);
|
|
295
|
+
if (h < 48)
|
|
296
|
+
return `${h}h ago`;
|
|
297
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
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
|
+
}
|
|
308
|
+
function pruneStepsFor(record, opts) {
|
|
309
|
+
const steps = [];
|
|
310
|
+
const skipped = [];
|
|
311
|
+
for (const b of record.branches) {
|
|
312
|
+
const wt = b.worktree ? record.worktrees.find((w) => w.id === b.worktree) : undefined;
|
|
313
|
+
const reason = opts.skip(b, wt);
|
|
314
|
+
if (reason) {
|
|
315
|
+
if (b.merged.status === "merged" && !b.is_default)
|
|
316
|
+
skipped.push({ branch: b.name, reason });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const commands = [];
|
|
320
|
+
if (wt)
|
|
321
|
+
commands.push(`git worktree remove -- ${wt.path === null ? "<its worktree>" : shellQuote(wt.path)}`);
|
|
322
|
+
commands.push(`git branch -d -- ${shellQuote(b.name)}`);
|
|
323
|
+
steps.push({
|
|
324
|
+
branch: b.name, head: b.head, worktree: wt ? { id: wt.id, path: wt.path } : null, commands,
|
|
325
|
+
why: `${b.merged.method}${b.merged.pr ? ` (PR #${b.merged.pr})` : ""}: ${b.merged.evidence[0] ?? ""}`,
|
|
326
|
+
method: b.merged.method,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return { steps, skipped };
|
|
330
|
+
}
|
|
331
|
+
/** Why a branch must not be pruned, or null when it may. The rules are the documented ones:
|
|
332
|
+
* proven merged, not the default branch, worktree (if any) clean, unlocked and present. */
|
|
333
|
+
export function pruneRefusal(b, wt) {
|
|
334
|
+
if (b.is_default)
|
|
335
|
+
return "default branch";
|
|
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
|
+
}
|
|
339
|
+
if (wt?.is_main)
|
|
340
|
+
return "checked out in the main worktree (switch away first)";
|
|
341
|
+
if (wt?.locked)
|
|
342
|
+
return "worktree is locked";
|
|
343
|
+
if (wt?.prunable)
|
|
344
|
+
return "worktree path is missing (git worktree prune first)";
|
|
345
|
+
if (wt && wt.dirty !== false)
|
|
346
|
+
return wt.dirty === null ? "worktree state could not be read" : "worktree has uncommitted or untracked changes";
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
export function planPrune(live, others) {
|
|
350
|
+
const mine = pruneStepsFor(live, { skip: pruneRefusal });
|
|
351
|
+
const plan = { local: mine.steps, others: {}, skipped: mine.skipped };
|
|
352
|
+
for (const record of latestPerMachine(others.filter((r) => r.machine.id !== live.machine.id))) {
|
|
353
|
+
const theirs = pruneStepsFor(record, { skip: pruneRefusal });
|
|
354
|
+
if (theirs.steps.length)
|
|
355
|
+
plan.others[record.machine.label] = theirs.steps;
|
|
356
|
+
}
|
|
357
|
+
return plan;
|
|
358
|
+
}
|
|
359
|
+
//# sourceMappingURL=workspace.js.map
|
|
@@ -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;
|
package/dist/extractors/diff.js
CHANGED
|
@@ -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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)) {
|
package/dist/extractors/git.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
282
|
-
|
|
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
|
-
/**
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
/**
|
|
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
|
|
291
|
-
* Binary
|
|
292
|
-
*
|
|
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.
|