@davesheffer/hunch 1.38.1 → 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 (37) hide show
  1. package/dist/cli/index.js +240 -6
  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/types.d.ts +66 -1
  15. package/dist/core/types.js +3 -0
  16. package/dist/core/workspace.d.ts +234 -0
  17. package/dist/core/workspace.js +335 -0
  18. package/dist/extractors/helm.d.ts +17 -28
  19. package/dist/extractors/helm.js +12 -12
  20. package/dist/extractors/indexer.js +171 -7
  21. package/dist/extractors/k8sManifest.d.ts +59 -0
  22. package/dist/extractors/k8sManifest.js +507 -0
  23. package/dist/extractors/workspaces.d.ts +18 -0
  24. package/dist/extractors/workspaces.js +350 -0
  25. package/dist/integrations/claudemd.js +1 -0
  26. package/dist/integrations/hooks.d.ts +2 -0
  27. package/dist/integrations/hooks.js +25 -0
  28. package/dist/integrations/scaffold.js +11 -0
  29. package/dist/integrations/workspaceLedger.d.ts +73 -0
  30. package/dist/integrations/workspaceLedger.js +201 -0
  31. package/dist/mcp/server.js +54 -0
  32. package/dist/serve/app.d.ts +2 -0
  33. package/dist/serve/app.js +107 -92
  34. package/dist/serve/mcpHttp.d.ts +27 -0
  35. package/dist/serve/mcpHttp.js +95 -0
  36. package/package.json +1 -1
  37. package/server.json +2 -2
@@ -9,12 +9,28 @@ import type { HunchPaths } from "./paths.js";
9
9
  export type Firmness = "off" | "advisory" | "firm" | "strict";
10
10
  export declare const FIRMNESS_LEVELS: readonly Firmness[];
11
11
  export declare const DEFAULT_FIRMNESS: Firmness;
12
+ /** Workspace-ledger knobs (docs/workspace-ledger.md). `publish` decides what a snapshot
13
+ * carries: `branches` (default — label, branches, verdicts, dirty/locked flags, no paths),
14
+ * `full` (worktree paths too), `off` (no record). `publish_public` lets a repo WITHOUT an
15
+ * overlay commit the record into its tracked .hunch/ — off by default: per-machine facts
16
+ * churning the code repo is rarely wanted. */
17
+ export type WorkspacePublish = "full" | "branches" | "off";
18
+ export declare const WORKSPACE_PUBLISH_MODES: readonly WorkspacePublish[];
19
+ export declare const DEFAULT_WORKSPACE_PUBLISH: WorkspacePublish;
20
+ export interface WorkspacesConfig {
21
+ publish: WorkspacePublish;
22
+ stale_after_days: number;
23
+ publish_public: boolean;
24
+ }
12
25
  export interface HunchConfig {
13
26
  firmness: Firmness;
14
27
  /** MCP tool groups beyond the everyday set: `all`, `core`, or `core,nuryel`
15
28
  * (see src/mcp/toolset.ts). Undefined = decide from the root's contents. */
16
29
  mcp_tools?: string;
30
+ workspaces?: Partial<WorkspacesConfig>;
17
31
  }
32
+ /** The effective workspace config: every field present, unknown values ignored. */
33
+ export declare function workspacesConfig(config: HunchConfig): WorkspacesConfig;
18
34
  export declare function isFirmness(v: unknown): v is Firmness;
19
35
  /** Read `.hunch/config.json`. A missing/unparseable file, or an unknown firmness
20
36
  * value, falls back to defaults — the hook must NEVER crash an edit over config. */
@@ -6,6 +6,18 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
6
  import { dirname } from "node:path";
7
7
  export const FIRMNESS_LEVELS = ["off", "advisory", "firm", "strict"];
8
8
  export const DEFAULT_FIRMNESS = "advisory";
9
+ export const WORKSPACE_PUBLISH_MODES = ["full", "branches", "off"];
10
+ export const DEFAULT_WORKSPACE_PUBLISH = "branches";
11
+ /** The effective workspace config: every field present, unknown values ignored. */
12
+ export function workspacesConfig(config) {
13
+ const raw = config.workspaces ?? {};
14
+ const days = Number(raw.stale_after_days);
15
+ return {
16
+ publish: WORKSPACE_PUBLISH_MODES.includes(raw.publish) ? raw.publish : DEFAULT_WORKSPACE_PUBLISH,
17
+ stale_after_days: Number.isInteger(days) && days >= 1 && days <= 3650 ? days : 7,
18
+ publish_public: raw.publish_public === true,
19
+ };
20
+ }
9
21
  function defaults() {
10
22
  return { firmness: DEFAULT_FIRMNESS };
11
23
  }
@@ -22,6 +34,7 @@ export function readConfig(paths) {
22
34
  return {
23
35
  firmness: isFirmness(raw.firmness) ? raw.firmness : DEFAULT_FIRMNESS,
24
36
  ...(typeof raw.mcp_tools === "string" && raw.mcp_tools.trim() ? { mcp_tools: raw.mcp_tools.trim() } : {}),
37
+ ...(raw.workspaces && typeof raw.workspaces === "object" && !Array.isArray(raw.workspaces) ? { workspaces: raw.workspaces } : {}),
25
38
  };
26
39
  }
27
40
  catch {
@@ -0,0 +1,20 @@
1
+ export interface MachineIdentity {
2
+ id: string;
3
+ label: string;
4
+ created_at: string;
5
+ }
6
+ export interface MachinePathOptions {
7
+ env?: NodeJS.ProcessEnv;
8
+ home?: string;
9
+ platform?: NodeJS.Platform;
10
+ }
11
+ export declare function machineFile(opts?: MachinePathOptions): string;
12
+ export declare function defaultMachineLabel(id: string): string;
13
+ /** The machine's identity, minted on first use. An unreadable or invalid file is
14
+ * replaced (a machine that lost its id simply becomes a new machine; the old record
15
+ * ages out as unverified and `hunch workspaces forget` removes it). */
16
+ export declare function loadOrCreateMachine(opts?: MachinePathOptions): MachineIdentity;
17
+ export declare function setMachineLabel(label: string, opts?: MachinePathOptions): MachineIdentity;
18
+ /** A label that equals the hostname or the OS username publishes personal data into a
19
+ * shared store; `doctor` and `label` warn, they do not refuse — the user chose it. */
20
+ export declare function labelLeaksIdentity(label: string): string | null;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Machine identity for the workspace ledger (docs/workspace-ledger.md): a random id
3
+ * generated ONCE per machine and stored at the user level, so every clone on the
4
+ * machine reports as the same machine. Deliberately NOT derived from the hostname,
5
+ * a MAC address or a hardware serial — it identifies nothing outside Hunch. The
6
+ * label is user-chosen; the default embeds nothing personal.
7
+ *
8
+ * Lives under the platform's per-user config root (XDG_CONFIG_HOME / %APPDATA% /
9
+ * ~/.config) and, like updatecheck.ts, never creates a `.hunch` path segment: that
10
+ * is findRoot()'s repository marker.
11
+ */
12
+ import { lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { homedir, hostname, userInfo } from "node:os";
14
+ import { basename, dirname, join } from "node:path";
15
+ import { randomBytes, randomUUID } from "node:crypto";
16
+ import { MACHINE_ID, MACHINE_LABEL } from "./workspace.js";
17
+ const MAX_MACHINE_FILE_BYTES = 4096;
18
+ function configuredRoot(value, platform) {
19
+ if (!value)
20
+ return null;
21
+ const absolute = platform === "win32" ? /^(?:[A-Za-z]:[\\/]|\\\\)/.test(value) : value.startsWith("/");
22
+ const marker = value.replace(/\\/g, "/").split("/").some((part) => part.toLowerCase().replace(/[ .]+$/g, "") === ".hunch");
23
+ return absolute && !marker ? value : null;
24
+ }
25
+ export function machineFile(opts = {}) {
26
+ const env = opts.env ?? process.env;
27
+ const home = opts.home ?? homedir();
28
+ const platform = opts.platform ?? process.platform;
29
+ const configHome = configuredRoot(env.XDG_CONFIG_HOME, platform)
30
+ || (platform === "win32" && configuredRoot(env.APPDATA, platform))
31
+ || join(home, ".config");
32
+ return join(configHome, "hunch", "machine.json");
33
+ }
34
+ export function defaultMachineLabel(id) {
35
+ return `machine-${id.replace(/^mac_/, "").slice(0, 4)}`;
36
+ }
37
+ function readMachine(file) {
38
+ try {
39
+ const stat = lstatSync(file);
40
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_MACHINE_FILE_BYTES)
41
+ return null;
42
+ const raw = JSON.parse(readFileSync(file, "utf8"));
43
+ if (typeof raw.id !== "string" || !MACHINE_ID.test(raw.id))
44
+ return null;
45
+ const label = typeof raw.label === "string" && MACHINE_LABEL.test(raw.label) ? raw.label : defaultMachineLabel(raw.id);
46
+ const created = typeof raw.created_at === "string" && Number.isFinite(Date.parse(raw.created_at)) ? raw.created_at : new Date(0).toISOString();
47
+ return { id: raw.id, label, created_at: created };
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** Atomic, owner-only write: a half-written id file would mint a second machine. */
54
+ function writeMachine(file, identity) {
55
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
56
+ const temp = join(dirname(file), `.${basename(file)}.${process.pid}.${randomUUID()}.tmp`);
57
+ writeFileSync(temp, JSON.stringify(identity, null, 2) + "\n", { mode: 0o600 });
58
+ renameSync(temp, file);
59
+ }
60
+ /** The machine's identity, minted on first use. An unreadable or invalid file is
61
+ * replaced (a machine that lost its id simply becomes a new machine; the old record
62
+ * ages out as unverified and `hunch workspaces forget` removes it). */
63
+ export function loadOrCreateMachine(opts = {}) {
64
+ const file = machineFile(opts);
65
+ const existing = readMachine(file);
66
+ if (existing)
67
+ return existing;
68
+ const id = `mac_${randomBytes(16).toString("hex")}`;
69
+ const fresh = { id, label: defaultMachineLabel(id), created_at: new Date().toISOString() };
70
+ writeMachine(file, fresh);
71
+ return fresh;
72
+ }
73
+ export function setMachineLabel(label, opts = {}) {
74
+ if (!MACHINE_LABEL.test(label)) {
75
+ throw new Error("machine label must be 1-64 characters of letters, digits, '.', '_' or '-' and start with a letter or digit");
76
+ }
77
+ const next = { ...loadOrCreateMachine(opts), label };
78
+ writeMachine(machineFile(opts), next);
79
+ return next;
80
+ }
81
+ /** A label that equals the hostname or the OS username publishes personal data into a
82
+ * shared store; `doctor` and `label` warn, they do not refuse — the user chose it. */
83
+ export function labelLeaksIdentity(label) {
84
+ const lower = label.toLowerCase();
85
+ let host = "";
86
+ let user = "";
87
+ try {
88
+ host = hostname().toLowerCase();
89
+ }
90
+ catch { /* unavailable */ }
91
+ try {
92
+ user = userInfo().username.toLowerCase();
93
+ }
94
+ catch { /* unavailable */ }
95
+ if (host && (lower === host || lower === host.split(".")[0]))
96
+ return "hostname";
97
+ if (user && lower === user)
98
+ return "OS username";
99
+ return null;
100
+ }
101
+ //# sourceMappingURL=machine.js.map
@@ -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;