@davesheffer/hunch 1.38.0 → 1.39.0

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 +255 -9
  2. package/dist/cli/serve.js +1 -0
  3. package/dist/client/readOrCompute.d.ts +77 -0
  4. package/dist/client/readOrCompute.js +85 -0
  5. package/dist/client/state.d.ts +1 -0
  6. package/dist/client/state.js +1 -0
  7. package/dist/constitution/g2.d.ts +1 -0
  8. package/dist/constitution/service.js +8 -0
  9. package/dist/constitution/sourceMutation.js +23 -18
  10. package/dist/core/config.d.ts +16 -0
  11. package/dist/core/config.js +13 -0
  12. package/dist/core/machine.d.ts +20 -0
  13. package/dist/core/machine.js +101 -0
  14. package/dist/core/taskRecord.js +6 -3
  15. package/dist/core/taskReport.d.ts +25 -2
  16. package/dist/core/taskReport.js +92 -18
  17. package/dist/core/taskReportEvidence.d.ts +4 -1
  18. package/dist/core/taskReportEvidence.js +5 -2
  19. package/dist/core/taskReportHook.d.ts +16 -3
  20. package/dist/core/taskReportHook.js +65 -13
  21. package/dist/core/taskTouched.d.ts +4 -0
  22. package/dist/core/taskTouched.js +30 -10
  23. package/dist/core/types.d.ts +66 -1
  24. package/dist/core/types.js +3 -0
  25. package/dist/core/workspace.d.ts +234 -0
  26. package/dist/core/workspace.js +335 -0
  27. package/dist/extractors/helm.d.ts +17 -28
  28. package/dist/extractors/helm.js +12 -12
  29. package/dist/extractors/indexer.js +171 -7
  30. package/dist/extractors/k8sManifest.d.ts +59 -0
  31. package/dist/extractors/k8sManifest.js +507 -0
  32. package/dist/extractors/workspaces.d.ts +18 -0
  33. package/dist/extractors/workspaces.js +350 -0
  34. package/dist/integrations/claudemd.js +1 -0
  35. package/dist/integrations/hooks.d.ts +2 -0
  36. package/dist/integrations/hooks.js +25 -0
  37. package/dist/integrations/scaffold.js +11 -0
  38. package/dist/integrations/workspaceLedger.d.ts +73 -0
  39. package/dist/integrations/workspaceLedger.js +201 -0
  40. package/dist/mcp/server.js +54 -0
  41. package/dist/mcp/taskReportTools.d.ts +9 -0
  42. package/dist/mcp/taskReportTools.js +13 -5
  43. package/dist/serve/app.d.ts +2 -0
  44. package/dist/serve/app.js +107 -92
  45. package/dist/serve/mcpHttp.d.ts +27 -0
  46. package/dist/serve/mcpHttp.js +95 -0
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { findRoot } from "./paths.js";
6
6
  import { canonicalReportRoot } from "./taskReportPaths.js";
7
7
  import { isCredentialFreeText } from "./types.js";
8
- import { continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, startReportTask } from "./taskReport.js";
8
+ import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
9
9
  import { reportSourceSnapshot } from "./taskReportEvidence.js";
10
10
  import { renderTaskReport } from "./taskReportRender.js";
11
11
  /** The exact task identity a native host prompt maps to. */
@@ -73,7 +73,14 @@ function identity(root, provider, event) {
73
73
  return null;
74
74
  if (!event.prompt_id)
75
75
  return "legacy";
76
- return promptTaskId(root, event.session_id, event.prompt_id, event.agent_id ?? null, provider);
76
+ const id = promptTaskId(root, event.session_id, event.prompt_id, event.agent_id ?? null, provider);
77
+ // A notification turn reports to the task it continued (an explicit alias).
78
+ try {
79
+ return resolveReportTask(root, id);
80
+ }
81
+ catch {
82
+ return id;
83
+ }
77
84
  }
78
85
  export function hookReportTaskId(root, provider, event) {
79
86
  try {
@@ -103,11 +110,19 @@ export function startHookReport(root, provider, event) {
103
110
  // the episode's graph record is written under the first task's id. The key
104
111
  // is a hash; the host session identifier itself is still never retained.
105
112
  let links = {};
106
- if (event.session_id) {
107
- const sessionKey = reportHash([cwd, provider, event.session_id, event.agent_id ?? null]);
113
+ const sessionKey = hookSessionKey(cwd, provider, event);
114
+ if (sessionKey) {
108
115
  links = { session_key: sessionKey };
109
116
  try {
110
117
  const previous = latestSessionTask(root, sessionKey);
118
+ // A host notification (a background command finished) is not new work:
119
+ // it continues the session's latest task instead of opening an empty row,
120
+ // unless the agent already closed that task for good. The alias makes
121
+ // this prompt's Stop and hook observations report to that task.
122
+ if (previous && previous.task_id !== id && isNotificationPrompt(event.prompt) && previous.closed_by !== "agent") {
123
+ aliasReportTask(root, id, previous.task_id);
124
+ return taskInstruction(previous, cwdLiteral);
125
+ }
111
126
  const continued = previous && previous.task_id !== id ? continuationLinks(previous) : null;
112
127
  if (continued)
113
128
  links = { ...links, ...continued };
@@ -127,8 +142,41 @@ export function startHookReport(root, provider, event) {
127
142
  throw error;
128
143
  task = existing;
129
144
  }
145
+ return taskInstruction(task, cwdLiteral);
146
+ }
147
+ function taskInstruction(task, cwdLiteral) {
130
148
  return `Hunch has opened this prompt's report: ${task.task_id}. Reuse this exact ID for this prompt. Call hunch_task(action: "start", task_id: "${task.task_id}", title: ${JSON.stringify(task.title)}, cwd: ${cwdLiteral}) to obtain verification_argv; do not create another report. Pass this task_id and cwd: ${cwdLiteral} to hunch_context and decision/correction/finding captures, and pass the same cwd when finishing with hunch_task before responding. A host Stop notice will show the evidence even if no task-linked memory was observed.`;
131
149
  }
150
+ /** The session key a hook event maps to: a hash of (root, provider, session,
151
+ * agent), never the identifier itself. Null without a host session. */
152
+ function hookSessionKey(cwd, provider, event) {
153
+ return event.session_id ? reportHash([cwd, provider, event.session_id, event.agent_id ?? null]) : null;
154
+ }
155
+ /** A prompt the host generated to report a background command's completion,
156
+ * not something the user typed. */
157
+ export function isNotificationPrompt(prompt) {
158
+ if (typeof prompt !== "string")
159
+ return false;
160
+ const firstLine = prompt.split(/\r?\n/).map(l => l.trim()).find(l => l.length > 0) ?? "";
161
+ return /^<task-notification>/i.test(firstLine);
162
+ }
163
+ /** Close, as host closes, the tasks of this session that an earlier prompt left
164
+ * open: the prompt was interrupted before its Stop, or a late observation
165
+ * reopened its task. Called when a new prompt starts (`keepNewest`: the new
166
+ * task, or the task a notification turn continues, stays open) and when the
167
+ * current prompt stops. Returns the ids closed here for the caller to persist. */
168
+ export function settleHookSession(root, provider, event, options = {}) {
169
+ const cwd = nativeHookCwd(root, provider, event);
170
+ const key = cwd ? hookSessionKey(cwd, provider, event) : null;
171
+ if (!key)
172
+ return [];
173
+ try {
174
+ return settleSessionTasks(root, key, options);
175
+ }
176
+ catch {
177
+ return [];
178
+ }
179
+ }
132
180
  /** Stop ends the turn, so the prompt's task closes here as a HOST close: the
133
181
  * ledger says the task completed even when the agent never called finish, and
134
182
  * a task with observations becomes a graph record without anyone's cooperation.
@@ -136,30 +184,34 @@ export function startHookReport(root, provider, event) {
136
184
  * continuation: the next observation reopens the task and the following Stop
137
185
  * closes it again (the record is refreshed from the report). An explicit agent
138
186
  * finish with any outcome overrides a host close. Pending verification keeps
139
- * the task open. Returns the task id when the task is closed after this call,
140
- * so the caller can persist its record; null when nothing is closed. */
187
+ * the task open. Tasks an earlier prompt of the session left open close here
188
+ * too. Returns the ids of the tasks closed after this call, so the caller can
189
+ * persist their records; empty when nothing is closed. */
141
190
  export function closeHookTask(root, provider, event) {
142
191
  let id;
143
192
  try {
144
193
  id = identity(root, provider, event);
145
194
  }
146
195
  catch {
147
- return null;
196
+ return [];
148
197
  }
149
198
  if (!id || id === "legacy")
150
- return null;
199
+ return [];
200
+ const closed = [];
151
201
  try {
152
202
  const task = readTaskReport(root, id).task;
153
- if (task.state === "interrupted")
154
- return null;
155
203
  if (task.state === "open")
156
204
  finishReportTask(root, id, "completed", { by: "host" });
157
- return id;
205
+ if (task.state !== "interrupted")
206
+ closed.push(id);
158
207
  }
159
208
  catch {
160
- // No task for this prompt, or verification still running: leave it as it is.
161
- return null;
209
+ // No task for this prompt (a notification turn), or verification still running: leave it as it is.
162
210
  }
211
+ for (const other of settleHookSession(root, provider, event, { keepId: id }))
212
+ if (!closed.includes(other))
213
+ closed.push(other);
214
+ return closed;
163
215
  }
164
216
  /** A presentation notice never denies Stop or injects another model turn.
165
217
  * A prompt with no observation at all prints nothing: the empty task row stays
@@ -1,5 +1,9 @@
1
+ /** Files Hunch regenerates on every capture (src/integrations/providers.ts,
2
+ * claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
3
+ export declare const HUNCH_MANAGED_FILES: ReadonlySet<string>;
1
4
  export declare function gitTouchedFiles(root: string, startedAt: string, finishedAt: string | null, options?: {
2
5
  limit?: number;
3
6
  timeoutMs?: number;
4
7
  now?: number;
8
+ workingTree?: boolean;
5
9
  }): string[];
@@ -8,15 +8,26 @@
8
8
  * never a failed finish):
9
9
  * - commits authored by the configured git user whose commit time falls in
10
10
  * the task window (merges excluded);
11
- * - working-tree changes (modified, added, untracked) whose mtime falls in it.
12
- * Hunch's own memory and cache paths are excluded, so a capture commit made
13
- * during the task does not count as work on a file. Deleted paths are skipped:
14
- * nothing dates the deletion. Commit dates get one second of slack (git keeps
15
- * seconds); working-tree mtimes get none before the start. */
11
+ * - working-tree changes (modified, added, untracked) whose mtime falls in it,
12
+ * unless the caller knows another session shared the checkout (`workingTree:
13
+ * false`): mtimes cannot say whose edit it was, commits can.
14
+ * Hunch's own work never counts as the task's: memory and cache paths, commits
15
+ * Hunch makes (`hunch:` subjects — captures, task records, repairs), and the
16
+ * grounding files a capture rewrites (CLAUDE.md, AGENTS.md, the host rule
17
+ * files) when they merely changed in the working tree; a user commit that
18
+ * edits one of those files still counts, as does a delivery that named it.
19
+ * Deleted paths are skipped: nothing dates the deletion. Commit dates get one
20
+ * second of slack (git keeps seconds); working-tree mtimes get none before
21
+ * the start. */
16
22
  import { execFileSync } from "node:child_process";
17
23
  import { statSync } from "node:fs";
18
24
  import { join } from "node:path";
19
25
  const EXCLUDED_SEGMENTS = new Set([".hunch", ".hunch-cache", ".git"]);
26
+ /** Files Hunch regenerates on every capture (src/integrations/providers.ts,
27
+ * claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
28
+ export const HUNCH_MANAGED_FILES = new Set(["CLAUDE.md", "AGENTS.md", ".cursor/rules/hunch.mdc", ".github/copilot-instructions.md", ".windsurf/rules/hunch.md"]);
29
+ /** Commit subjects Hunch writes itself. */
30
+ const HUNCH_COMMIT_SUBJECT = /^hunch:/i;
20
31
  function gitDate(ms) {
21
32
  // Second resolution, a format every git accepts.
22
33
  return `${new Date(ms).toISOString().slice(0, 19).replace("T", " ")} +0000`;
@@ -38,8 +49,9 @@ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
38
49
  delete env[key];
39
50
  const run = (args) => execFileSync("git", ["-C", root, "-c", "core.quotePath=false", ...args], { env, encoding: "utf8", timeout, maxBuffer: 4_000_000, stdio: ["ignore", "pipe", "ignore"] });
40
51
  const out = new Set();
52
+ const normalize = (raw) => raw.trim().replace(/\\/g, "/").replace(/^\.\//, "");
41
53
  const keep = (raw) => {
42
- const path = raw.trim().replace(/\\/g, "/").replace(/^\.\//, "");
54
+ const path = normalize(raw);
43
55
  if (!path || path.split("/").some((segment) => EXCLUDED_SEGMENTS.has(segment)))
44
56
  return;
45
57
  out.add(path);
@@ -47,12 +59,20 @@ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
47
59
  try {
48
60
  const email = run(["config", "--get", "user.email"]).trim();
49
61
  if (email) {
50
- const log = run(["log", "--no-merges", "-n", "50", `--since=${gitDate(from)}`, `--until=${gitDate(to)}`, `--author=${email}`, "--format=", "--name-only"]);
51
- for (const line of log.split("\n"))
52
- keep(line);
62
+ // One record per commit: a separator, the subject, then the paths.
63
+ const log = run(["log", "--no-merges", "-n", "50", `--since=${gitDate(from)}`, `--until=${gitDate(to)}`, `--author=${email}`, "--format=%x1e%s", "--name-only"]);
64
+ for (const block of log.split("\x1e")) {
65
+ const [subject = "", ...paths] = block.split("\n");
66
+ if (HUNCH_COMMIT_SUBJECT.test(subject.trim()))
67
+ continue;
68
+ for (const line of paths)
69
+ keep(line);
70
+ }
53
71
  }
54
72
  }
55
73
  catch { /* no commits, no git user, or no git: the working tree may still say something */ }
74
+ if (options.workingTree === false)
75
+ return [...out].sort().slice(0, limit);
56
76
  try {
57
77
  const entries = run(["status", "--porcelain=v1", "-z", "--untracked-files=all"]).split("\0").filter(Boolean);
58
78
  for (let i = 0; i < entries.length; i++) {
@@ -61,7 +81,7 @@ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
61
81
  // A rename or copy is followed by its original path as a separate entry.
62
82
  if (code[0] === "R" || code[0] === "C")
63
83
  i++;
64
- if (code.includes("D") || !path)
84
+ if (code.includes("D") || !path || HUNCH_MANAGED_FILES.has(normalize(path)))
65
85
  continue;
66
86
  try {
67
87
  const mtime = statSync(join(root, path)).mtimeMs;
@@ -8,6 +8,7 @@
8
8
  import { z } from "zod";
9
9
  import { ProvenanceSchema, isCredentialFreeText, type Provenance } from "./provenance.js";
10
10
  import { type Convention, type ActionReceipt, type Commitment, type DerivedState, type ExternalEntity, type StateRelationship } from "./stateRecords.js";
11
+ import { type Workspace } from "./workspace.js";
11
12
  export { ProvenanceSchema, isCredentialFreeText };
12
13
  export type { Provenance };
13
14
  export declare const ComponentKind: z.ZodEnum<{
@@ -701,7 +702,7 @@ export declare function assertLandscapeDriftCandidate(value: unknown): asserts v
701
702
  /** Convert one valid external observation into advisory Hunch memory, never graph authority. */
702
703
  export declare function landscapeDriftCandidateFinding(value: unknown): Finding;
703
704
  /** The entity collections, keyed by their on-disk directory name. */
704
- export declare const ENTITY_KINDS: readonly ["components", "resources", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings", "receipts", "commitments", "derived", "entities", "relationships", "conventions", "tasks"];
705
+ export declare const ENTITY_KINDS: readonly ["components", "resources", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings", "receipts", "commitments", "derived", "entities", "relationships", "conventions", "tasks", "workspaces"];
705
706
  export type EntityKind = (typeof ENTITY_KINDS)[number];
706
707
  export declare const SCHEMAS: {
707
708
  readonly components: z.ZodObject<{
@@ -1534,6 +1535,69 @@ export declare const SCHEMAS: {
1534
1535
  last_verified: z.ZodOptional<z.ZodString>;
1535
1536
  }, z.core.$strip>;
1536
1537
  }, z.core.$strip>;
1538
+ readonly workspaces: z.ZodObject<{
1539
+ schema: z.ZodLiteral<"hunch.workspace/1">;
1540
+ id: z.ZodString;
1541
+ machine: z.ZodObject<{
1542
+ id: z.ZodString;
1543
+ label: z.ZodString;
1544
+ platform: z.ZodString;
1545
+ }, z.core.$strict>;
1546
+ repository: z.ZodString;
1547
+ publish: z.ZodEnum<{
1548
+ full: "full";
1549
+ branches: "branches";
1550
+ }>;
1551
+ observed_at: z.ZodString;
1552
+ fetched_at: z.ZodNullable<z.ZodString>;
1553
+ default_branch: z.ZodNullable<z.ZodObject<{
1554
+ name: z.ZodString;
1555
+ ref: z.ZodUnion<[z.ZodString, z.ZodString]>;
1556
+ head: z.ZodString;
1557
+ }, z.core.$strict>>;
1558
+ worktrees: z.ZodArray<z.ZodObject<{
1559
+ id: z.ZodString;
1560
+ path: z.ZodNullable<z.ZodString>;
1561
+ branch: z.ZodNullable<z.ZodString>;
1562
+ head: z.ZodString;
1563
+ is_main: z.ZodBoolean;
1564
+ dirty: z.ZodNullable<z.ZodBoolean>;
1565
+ locked: z.ZodBoolean;
1566
+ prunable: z.ZodBoolean;
1567
+ last_commit_at: z.ZodNullable<z.ZodString>;
1568
+ }, z.core.$strict>>;
1569
+ branches: z.ZodArray<z.ZodObject<{
1570
+ name: z.ZodString;
1571
+ head: z.ZodString;
1572
+ is_default: z.ZodBoolean;
1573
+ upstream: z.ZodNullable<z.ZodString>;
1574
+ upstream_gone: z.ZodBoolean;
1575
+ ahead: z.ZodNullable<z.ZodNumber>;
1576
+ behind: z.ZodNullable<z.ZodNumber>;
1577
+ last_commit_at: z.ZodNullable<z.ZodString>;
1578
+ worktree: z.ZodNullable<z.ZodString>;
1579
+ merged: z.ZodObject<{
1580
+ status: z.ZodEnum<{
1581
+ unknown: "unknown";
1582
+ merged: "merged";
1583
+ unmerged: "unmerged";
1584
+ }>;
1585
+ method: z.ZodNullable<z.ZodEnum<{
1586
+ ancestry: "ancestry";
1587
+ squash: "squash";
1588
+ rebase: "rebase";
1589
+ }>>;
1590
+ evidence: z.ZodArray<z.ZodString>;
1591
+ pr: z.ZodOptional<z.ZodNumber>;
1592
+ }, z.core.$strict>;
1593
+ }, z.core.$strict>>;
1594
+ provenance: z.ZodObject<{
1595
+ source: z.ZodString;
1596
+ confidence: z.ZodNumber;
1597
+ evidence: z.ZodDefault<z.ZodArray<z.ZodString>>;
1598
+ last_verified: z.ZodOptional<z.ZodString>;
1599
+ }, z.core.$strip>;
1600
+ }, z.core.$strict>;
1537
1601
  };
1538
1602
  export type EntityFor = {
1539
1603
  components: Component;
@@ -1552,6 +1616,7 @@ export type EntityFor = {
1552
1616
  entities: ExternalEntity;
1553
1617
  relationships: StateRelationship;
1554
1618
  tasks: TaskRecord;
1619
+ workspaces: Workspace;
1555
1620
  };
1556
1621
  /** Default provenance helper for deterministic (extracted) records. */
1557
1622
  export declare function extracted(confidence: number, evidence?: string[]): Provenance;
@@ -11,6 +11,7 @@ import { createHash } from "node:crypto";
11
11
  import { findingId, resourceId, resourceRelationshipId } from "./ids.js";
12
12
  import { ProvenanceSchema, SENSITIVE_METADATA_KEY, isCredentialFreeText } from "./provenance.js";
13
13
  import { ConventionSchema, ActionReceiptSchema, CommitmentSchema, DerivedStateSchema, ExternalEntitySchema, StateRelationshipSchema, } from "./stateRecords.js";
14
+ import { WorkspaceSchema } from "./workspace.js";
14
15
  // Provenance and the credential-free text check live in the leaf module ./provenance.js so
15
16
  // record schemas registered below can import them without a cycle; re-exported unchanged.
16
17
  export { ProvenanceSchema, isCredentialFreeText };
@@ -639,6 +640,7 @@ export function landscapeDriftCandidateFinding(value) {
639
640
  export const ENTITY_KINDS = [
640
641
  "components", "resources", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings",
641
642
  "receipts", "commitments", "derived", "entities", "relationships", "conventions", "tasks",
643
+ "workspaces",
642
644
  ];
643
645
  export const SCHEMAS = {
644
646
  components: ComponentSchema,
@@ -657,6 +659,7 @@ export const SCHEMAS = {
657
659
  entities: ExternalEntitySchema,
658
660
  relationships: StateRelationshipSchema,
659
661
  tasks: TaskRecordSchema,
662
+ workspaces: WorkspaceSchema,
660
663
  };
661
664
  /** Default provenance helper for deterministic (extracted) records. */
662
665
  export function extracted(confidence, evidence = []) {
@@ -0,0 +1,234 @@
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
+ export declare const WORKSPACE_SCHEMA_VERSION: "hunch.workspace/1";
15
+ /** Record bounds. The extractor keeps the most recently committed entries and says so in
16
+ * provenance, so a huge repository degrades to a truncated record, never to a crash. */
17
+ export declare const MAX_WORKTREES = 512;
18
+ export declare const MAX_BRANCHES = 4096;
19
+ export declare const MACHINE_ID: RegExp;
20
+ export declare const MACHINE_LABEL: RegExp;
21
+ /** A branch name git would accept (`git check-ref-format --branch`), fail-closed: no
22
+ * leading `-` (flag smuggling), no control/whitespace characters, no `..`, `@{`,
23
+ * `.lock` suffix, leading/trailing `.` or `/`, and bounded length. Every real branch
24
+ * from `for-each-ref` passes; a crafted record cannot smuggle an argument. */
25
+ export declare function isSafeBranchName(name: string): boolean;
26
+ export declare const WorkspaceWorktreeSchema: z.ZodObject<{
27
+ id: z.ZodString;
28
+ path: z.ZodNullable<z.ZodString>;
29
+ branch: z.ZodNullable<z.ZodString>;
30
+ head: z.ZodString;
31
+ is_main: z.ZodBoolean;
32
+ dirty: z.ZodNullable<z.ZodBoolean>;
33
+ locked: z.ZodBoolean;
34
+ prunable: z.ZodBoolean;
35
+ last_commit_at: z.ZodNullable<z.ZodString>;
36
+ }, z.core.$strict>;
37
+ export type WorkspaceWorktree = z.infer<typeof WorkspaceWorktreeSchema>;
38
+ export declare const MERGED_STATUSES: readonly ["merged", "unmerged", "unknown"];
39
+ export declare const MERGED_METHODS: readonly ["ancestry", "squash", "rebase"];
40
+ export declare const MergedVerdictSchema: z.ZodObject<{
41
+ status: z.ZodEnum<{
42
+ unknown: "unknown";
43
+ merged: "merged";
44
+ unmerged: "unmerged";
45
+ }>;
46
+ method: z.ZodNullable<z.ZodEnum<{
47
+ ancestry: "ancestry";
48
+ squash: "squash";
49
+ rebase: "rebase";
50
+ }>>;
51
+ evidence: z.ZodArray<z.ZodString>;
52
+ pr: z.ZodOptional<z.ZodNumber>;
53
+ }, z.core.$strict>;
54
+ export type MergedVerdict = z.infer<typeof MergedVerdictSchema>;
55
+ export declare const WorkspaceBranchSchema: z.ZodObject<{
56
+ name: z.ZodString;
57
+ head: z.ZodString;
58
+ is_default: z.ZodBoolean;
59
+ upstream: z.ZodNullable<z.ZodString>;
60
+ upstream_gone: z.ZodBoolean;
61
+ ahead: z.ZodNullable<z.ZodNumber>;
62
+ behind: z.ZodNullable<z.ZodNumber>;
63
+ last_commit_at: z.ZodNullable<z.ZodString>;
64
+ worktree: z.ZodNullable<z.ZodString>;
65
+ merged: z.ZodObject<{
66
+ status: z.ZodEnum<{
67
+ unknown: "unknown";
68
+ merged: "merged";
69
+ unmerged: "unmerged";
70
+ }>;
71
+ method: z.ZodNullable<z.ZodEnum<{
72
+ ancestry: "ancestry";
73
+ squash: "squash";
74
+ rebase: "rebase";
75
+ }>>;
76
+ evidence: z.ZodArray<z.ZodString>;
77
+ pr: z.ZodOptional<z.ZodNumber>;
78
+ }, z.core.$strict>;
79
+ }, z.core.$strict>;
80
+ export type WorkspaceBranch = z.infer<typeof WorkspaceBranchSchema>;
81
+ export declare const WorkspaceSchema: z.ZodObject<{
82
+ schema: z.ZodLiteral<"hunch.workspace/1">;
83
+ id: z.ZodString;
84
+ machine: z.ZodObject<{
85
+ id: z.ZodString;
86
+ label: z.ZodString;
87
+ platform: z.ZodString;
88
+ }, z.core.$strict>;
89
+ repository: z.ZodString;
90
+ publish: z.ZodEnum<{
91
+ full: "full";
92
+ branches: "branches";
93
+ }>;
94
+ observed_at: z.ZodString;
95
+ fetched_at: z.ZodNullable<z.ZodString>;
96
+ default_branch: z.ZodNullable<z.ZodObject<{
97
+ name: z.ZodString;
98
+ ref: z.ZodUnion<[z.ZodString, z.ZodString]>;
99
+ head: z.ZodString;
100
+ }, z.core.$strict>>;
101
+ worktrees: z.ZodArray<z.ZodObject<{
102
+ id: z.ZodString;
103
+ path: z.ZodNullable<z.ZodString>;
104
+ branch: z.ZodNullable<z.ZodString>;
105
+ head: z.ZodString;
106
+ is_main: z.ZodBoolean;
107
+ dirty: z.ZodNullable<z.ZodBoolean>;
108
+ locked: z.ZodBoolean;
109
+ prunable: z.ZodBoolean;
110
+ last_commit_at: z.ZodNullable<z.ZodString>;
111
+ }, z.core.$strict>>;
112
+ branches: z.ZodArray<z.ZodObject<{
113
+ name: z.ZodString;
114
+ head: z.ZodString;
115
+ is_default: z.ZodBoolean;
116
+ upstream: z.ZodNullable<z.ZodString>;
117
+ upstream_gone: z.ZodBoolean;
118
+ ahead: z.ZodNullable<z.ZodNumber>;
119
+ behind: z.ZodNullable<z.ZodNumber>;
120
+ last_commit_at: z.ZodNullable<z.ZodString>;
121
+ worktree: z.ZodNullable<z.ZodString>;
122
+ merged: z.ZodObject<{
123
+ status: z.ZodEnum<{
124
+ unknown: "unknown";
125
+ merged: "merged";
126
+ unmerged: "unmerged";
127
+ }>;
128
+ method: z.ZodNullable<z.ZodEnum<{
129
+ ancestry: "ancestry";
130
+ squash: "squash";
131
+ rebase: "rebase";
132
+ }>>;
133
+ evidence: z.ZodArray<z.ZodString>;
134
+ pr: z.ZodOptional<z.ZodNumber>;
135
+ }, z.core.$strict>;
136
+ }, z.core.$strict>>;
137
+ provenance: z.ZodObject<{
138
+ source: z.ZodString;
139
+ confidence: z.ZodNumber;
140
+ evidence: z.ZodDefault<z.ZodArray<z.ZodString>>;
141
+ last_verified: z.ZodOptional<z.ZodString>;
142
+ }, z.core.$strip>;
143
+ }, z.core.$strict>;
144
+ export type Workspace = z.infer<typeof WorkspaceSchema>;
145
+ /** One record per machine: the id derives from the machine id, so a re-snapshot
146
+ * UPDATES the machine's record and two machines can never collide on a file. */
147
+ export declare function workspaceId(machineId: string): string;
148
+ /** Path-free worktree handle. */
149
+ export declare function worktreeId(path: string): string;
150
+ /** The same observation, published under `publish`: `branches` drops every worktree path
151
+ * (the default), `full` keeps them. Pure, so a caller that already took a live snapshot
152
+ * (paths included, for its own display) can publish it without re-running git. */
153
+ export declare function withPublishMode(record: Workspace, publish: "full" | "branches"): Workspace;
154
+ /** True when two snapshots of the same machine describe the same workspace, ignoring the
155
+ * observation stamps — so an idle machine's hook does not commit a new record per
156
+ * checkout. Provenance is constant per build and is compared too. */
157
+ export declare function sameWorkspaceContent(a: Workspace, b: Workspace): boolean;
158
+ export interface AggregateOptions {
159
+ /** Records older than this many days are reported as unverified. */
160
+ staleAfterDays?: number;
161
+ now?: Date;
162
+ }
163
+ export interface WorktreeRow {
164
+ machine: string;
165
+ worktree_id: string;
166
+ /** null in `branches` publish mode. */
167
+ path: string | null;
168
+ branch: string | null;
169
+ head: string;
170
+ dirty: boolean | null;
171
+ locked: boolean;
172
+ prunable: boolean;
173
+ last_commit_at: string | null;
174
+ seen_at: string;
175
+ unverified: boolean;
176
+ }
177
+ export interface BranchRow {
178
+ name: string;
179
+ /** Machine labels that hold this branch locally. */
180
+ machines: string[];
181
+ /** Machine labels with a worktree checked out on it. */
182
+ worktree_on: string[];
183
+ /** Machine labels whose worktree on it has uncommitted changes. */
184
+ dirty_on: string[];
185
+ /** Distinct heads across machines; more than one means the local branches diverged. */
186
+ heads: string[];
187
+ is_default: boolean;
188
+ upstream: string | null;
189
+ upstream_gone: boolean;
190
+ ahead: number | null;
191
+ behind: number | null;
192
+ last_commit_at: string | null;
193
+ merged: MergedVerdict;
194
+ /** Machine labels whose record is older than the staleness window. */
195
+ unverified_on: string[];
196
+ action: string;
197
+ }
198
+ export declare const DEFAULT_STALE_AFTER_DAYS = 7;
199
+ export declare function isUnverified(record: Pick<Workspace, "observed_at">, opts?: AggregateOptions): boolean;
200
+ /** Same machine id → the newest observation wins; a stale duplicate never shadows a fresh one. */
201
+ export declare function latestPerMachine(records: readonly Workspace[]): Workspace[];
202
+ export declare function worktreeRows(records: readonly Workspace[], opts?: AggregateOptions): WorktreeRow[];
203
+ /** The recommendation rules from docs/workspace-ledger.md — deterministic text an agent
204
+ * or a human reads; nothing executes it. */
205
+ export declare function recommendAction(row: Omit<BranchRow, "action">, opts?: AggregateOptions): string;
206
+ export declare function branchRows(records: readonly Workspace[], opts?: AggregateOptions): BranchRow[];
207
+ /** "2h ago" / "9d ago" for the SEEN column. */
208
+ export declare function ago(iso: string, now?: Date): string;
209
+ export interface PruneStep {
210
+ branch: string;
211
+ head: string;
212
+ /** Worktree checked out on the branch, when one exists and can be removed first. */
213
+ worktree: {
214
+ id: string;
215
+ path: string | null;
216
+ } | null;
217
+ commands: string[];
218
+ why: string;
219
+ }
220
+ export interface PrunePlan {
221
+ /** Executable on this machine (live record). */
222
+ local: PruneStep[];
223
+ /** Display-only, keyed by machine label (stored records). */
224
+ others: Record<string, PruneStep[]>;
225
+ /** Branches this machine holds that were considered and left alone, with the reason. */
226
+ skipped: Array<{
227
+ branch: string;
228
+ reason: string;
229
+ }>;
230
+ }
231
+ /** Why a branch must not be pruned, or null when it may. The rules are the documented ones:
232
+ * proven merged, not the default branch, worktree (if any) clean, unlocked and present. */
233
+ export declare function pruneRefusal(b: WorkspaceBranch, wt: WorkspaceWorktree | undefined): string | null;
234
+ export declare function planPrune(live: Workspace, others: readonly Workspace[]): PrunePlan;