@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.
Files changed (54) hide show
  1. package/dist/cli/index.js +355 -55
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/cli/serve.js +1 -0
  4. package/dist/client/readOrCompute.d.ts +77 -0
  5. package/dist/client/readOrCompute.js +85 -0
  6. package/dist/client/state.d.ts +1 -0
  7. package/dist/client/state.js +1 -0
  8. package/dist/constitution/g2.d.ts +1 -0
  9. package/dist/constitution/service.js +8 -0
  10. package/dist/constitution/sourceMutation.js +23 -18
  11. package/dist/core/agenthook.d.ts +14 -0
  12. package/dist/core/agenthook.js +48 -5
  13. package/dist/core/changeProof.js +5 -1
  14. package/dist/core/checkreport.d.ts +7 -0
  15. package/dist/core/checkreport.js +20 -3
  16. package/dist/core/compare.js +3 -2
  17. package/dist/core/config.d.ts +16 -0
  18. package/dist/core/config.js +13 -0
  19. package/dist/core/machine.d.ts +20 -0
  20. package/dist/core/machine.js +101 -0
  21. package/dist/core/taskReportEvidence.js +6 -6
  22. package/dist/core/types.d.ts +67 -1
  23. package/dist/core/types.js +3 -0
  24. package/dist/core/workspace.d.ts +256 -0
  25. package/dist/core/workspace.js +359 -0
  26. package/dist/extractors/diff.d.ts +34 -0
  27. package/dist/extractors/diff.js +147 -5
  28. package/dist/extractors/git.d.ts +40 -11
  29. package/dist/extractors/git.js +147 -43
  30. package/dist/extractors/helm.d.ts +17 -28
  31. package/dist/extractors/helm.js +12 -12
  32. package/dist/extractors/indexer.js +171 -7
  33. package/dist/extractors/k8sManifest.d.ts +59 -0
  34. package/dist/extractors/k8sManifest.js +507 -0
  35. package/dist/extractors/workspaces.d.ts +28 -0
  36. package/dist/extractors/workspaces.js +427 -0
  37. package/dist/integrations/claudemd.js +1 -0
  38. package/dist/integrations/gitignore.d.ts +27 -2
  39. package/dist/integrations/gitignore.js +103 -17
  40. package/dist/integrations/hooks.d.ts +63 -7
  41. package/dist/integrations/hooks.js +350 -38
  42. package/dist/integrations/scaffold.js +11 -0
  43. package/dist/integrations/workspaceLedger.d.ts +93 -0
  44. package/dist/integrations/workspaceLedger.js +307 -0
  45. package/dist/mcp/server.js +59 -5
  46. package/dist/serve/app.d.ts +2 -0
  47. package/dist/serve/app.js +107 -92
  48. package/dist/serve/mcpHttp.d.ts +27 -0
  49. package/dist/serve/mcpHttp.js +95 -0
  50. package/dist/store/hunchStore.d.ts +4 -2
  51. package/dist/store/hunchStore.js +23 -6
  52. package/dist/store/stateBinding.js +83 -35
  53. package/package.json +1 -1
  54. package/server.json +2 -2
@@ -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
@@ -5,7 +5,7 @@ import { canonicalReportRoot } from "./taskReportPaths.js";
5
5
  import { resolveSpawnCommand } from "./spawnCommand.js";
6
6
  import { dirname, isAbsolute, relative, resolve } from "node:path";
7
7
  import { analyzeDiff } from "../extractors/diff.js";
8
- import { workingDiff, workingFiles } from "../extractors/git.js";
8
+ import { workingGateDiff, workingFiles } from "../extractors/git.js";
9
9
  import { assertCompleteRepoScan, scanRepo } from "../extractors/indexer.js";
10
10
  import { checkConformance } from "./conformance.js";
11
11
  import { effectiveForbids, matchForbids } from "./constraintmatch.js";
@@ -104,9 +104,9 @@ export function runReportConformance(root, store, taskId) {
104
104
  const before = reportSourceSnapshot(root).hash;
105
105
  const files = workingFiles(root);
106
106
  const changed = new Set(files);
107
- const diff = workingDiff(root);
108
- const analysis = analyzeDiff(diff);
109
- const truncated = diff.endsWith("…(diff truncated)…");
107
+ const gate = workingGateDiff(root);
108
+ const analysis = analyzeDiff(gate.diff);
109
+ const unread = new Set(gate.unreadFiles ?? []);
110
110
  let graph;
111
111
  const workingGraph = () => {
112
112
  if (graph !== undefined)
@@ -141,8 +141,8 @@ export function runReportConformance(root, store, taskId) {
141
141
  note("constraint-forbids", "not-exercised", "No changed file falls in this constraint's scope.");
142
142
  continue;
143
143
  }
144
- if (truncated) {
145
- note("constraint-forbids", "unavailable", "The working diff exceeds the bounded analysis budget; added lines were not fully inspected.", scoped);
144
+ if (gate.incomplete || scoped.some(f => unread.has(f))) {
145
+ note("constraint-forbids", "unavailable", "The complete working diff could not be read; added lines were not fully inspected.", scoped);
146
146
  continue;
147
147
  }
148
148
  const match = matchForbids(forbids, new Set(analysis.addedDeps), scoped.flatMap(f => analysis.addedLinesByFile.get(f) ?? []));
@@ -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,70 @@ 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
+ "no-commits": "no-commits";
1585
+ }>;
1586
+ method: z.ZodNullable<z.ZodEnum<{
1587
+ ancestry: "ancestry";
1588
+ squash: "squash";
1589
+ rebase: "rebase";
1590
+ }>>;
1591
+ evidence: z.ZodArray<z.ZodString>;
1592
+ pr: z.ZodOptional<z.ZodNumber>;
1593
+ }, z.core.$strict>;
1594
+ }, z.core.$strict>>;
1595
+ provenance: z.ZodObject<{
1596
+ source: z.ZodString;
1597
+ confidence: z.ZodNumber;
1598
+ evidence: z.ZodDefault<z.ZodArray<z.ZodString>>;
1599
+ last_verified: z.ZodOptional<z.ZodString>;
1600
+ }, z.core.$strip>;
1601
+ }, z.core.$strict>;
1537
1602
  };
1538
1603
  export type EntityFor = {
1539
1604
  components: Component;
@@ -1552,6 +1617,7 @@ export type EntityFor = {
1552
1617
  entities: ExternalEntity;
1553
1618
  relationships: StateRelationship;
1554
1619
  tasks: TaskRecord;
1620
+ workspaces: Workspace;
1555
1621
  };
1556
1622
  /** Default provenance helper for deterministic (extracted) records. */
1557
1623
  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,256 @@
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
+ /** C0/C1 control characters, including newline and ESC: a stored string carrying one could
27
+ * forge extra lines or terminal escapes in output a human reads (a printed command). */
28
+ export declare const CONTROL_CHARS: RegExp;
29
+ export declare const WorkspaceWorktreeSchema: z.ZodObject<{
30
+ id: z.ZodString;
31
+ path: z.ZodNullable<z.ZodString>;
32
+ branch: z.ZodNullable<z.ZodString>;
33
+ head: z.ZodString;
34
+ is_main: z.ZodBoolean;
35
+ dirty: z.ZodNullable<z.ZodBoolean>;
36
+ locked: z.ZodBoolean;
37
+ prunable: z.ZodBoolean;
38
+ last_commit_at: z.ZodNullable<z.ZodString>;
39
+ }, z.core.$strict>;
40
+ export type WorkspaceWorktree = z.infer<typeof WorkspaceWorktreeSchema>;
41
+ /** `no-commits`: the branch head lies on the default branch's first-parent history, so the
42
+ * branch holds no commits of its own (freshly created, or fast-forwarded into the default
43
+ * branch). Ancestry alone would call it merged; it is never offered for deletion. */
44
+ export declare const MERGED_STATUSES: readonly ["merged", "unmerged", "no-commits", "unknown"];
45
+ export declare const MERGED_METHODS: readonly ["ancestry", "squash", "rebase"];
46
+ export declare const MergedVerdictSchema: z.ZodObject<{
47
+ status: z.ZodEnum<{
48
+ unknown: "unknown";
49
+ merged: "merged";
50
+ unmerged: "unmerged";
51
+ "no-commits": "no-commits";
52
+ }>;
53
+ method: z.ZodNullable<z.ZodEnum<{
54
+ ancestry: "ancestry";
55
+ squash: "squash";
56
+ rebase: "rebase";
57
+ }>>;
58
+ evidence: z.ZodArray<z.ZodString>;
59
+ pr: z.ZodOptional<z.ZodNumber>;
60
+ }, z.core.$strict>;
61
+ export type MergedVerdict = z.infer<typeof MergedVerdictSchema>;
62
+ export declare const WorkspaceBranchSchema: z.ZodObject<{
63
+ name: z.ZodString;
64
+ head: z.ZodString;
65
+ is_default: z.ZodBoolean;
66
+ upstream: z.ZodNullable<z.ZodString>;
67
+ upstream_gone: z.ZodBoolean;
68
+ ahead: z.ZodNullable<z.ZodNumber>;
69
+ behind: z.ZodNullable<z.ZodNumber>;
70
+ last_commit_at: z.ZodNullable<z.ZodString>;
71
+ worktree: z.ZodNullable<z.ZodString>;
72
+ merged: z.ZodObject<{
73
+ status: z.ZodEnum<{
74
+ unknown: "unknown";
75
+ merged: "merged";
76
+ unmerged: "unmerged";
77
+ "no-commits": "no-commits";
78
+ }>;
79
+ method: z.ZodNullable<z.ZodEnum<{
80
+ ancestry: "ancestry";
81
+ squash: "squash";
82
+ rebase: "rebase";
83
+ }>>;
84
+ evidence: z.ZodArray<z.ZodString>;
85
+ pr: z.ZodOptional<z.ZodNumber>;
86
+ }, z.core.$strict>;
87
+ }, z.core.$strict>;
88
+ export type WorkspaceBranch = z.infer<typeof WorkspaceBranchSchema>;
89
+ export declare const WorkspaceSchema: z.ZodObject<{
90
+ schema: z.ZodLiteral<"hunch.workspace/1">;
91
+ id: z.ZodString;
92
+ machine: z.ZodObject<{
93
+ id: z.ZodString;
94
+ label: z.ZodString;
95
+ platform: z.ZodString;
96
+ }, z.core.$strict>;
97
+ repository: z.ZodString;
98
+ publish: z.ZodEnum<{
99
+ full: "full";
100
+ branches: "branches";
101
+ }>;
102
+ observed_at: z.ZodString;
103
+ fetched_at: z.ZodNullable<z.ZodString>;
104
+ default_branch: z.ZodNullable<z.ZodObject<{
105
+ name: z.ZodString;
106
+ ref: z.ZodUnion<[z.ZodString, z.ZodString]>;
107
+ head: z.ZodString;
108
+ }, z.core.$strict>>;
109
+ worktrees: z.ZodArray<z.ZodObject<{
110
+ id: z.ZodString;
111
+ path: z.ZodNullable<z.ZodString>;
112
+ branch: z.ZodNullable<z.ZodString>;
113
+ head: z.ZodString;
114
+ is_main: z.ZodBoolean;
115
+ dirty: z.ZodNullable<z.ZodBoolean>;
116
+ locked: z.ZodBoolean;
117
+ prunable: z.ZodBoolean;
118
+ last_commit_at: z.ZodNullable<z.ZodString>;
119
+ }, z.core.$strict>>;
120
+ branches: z.ZodArray<z.ZodObject<{
121
+ name: z.ZodString;
122
+ head: z.ZodString;
123
+ is_default: z.ZodBoolean;
124
+ upstream: z.ZodNullable<z.ZodString>;
125
+ upstream_gone: z.ZodBoolean;
126
+ ahead: z.ZodNullable<z.ZodNumber>;
127
+ behind: z.ZodNullable<z.ZodNumber>;
128
+ last_commit_at: z.ZodNullable<z.ZodString>;
129
+ worktree: z.ZodNullable<z.ZodString>;
130
+ merged: z.ZodObject<{
131
+ status: z.ZodEnum<{
132
+ unknown: "unknown";
133
+ merged: "merged";
134
+ unmerged: "unmerged";
135
+ "no-commits": "no-commits";
136
+ }>;
137
+ method: z.ZodNullable<z.ZodEnum<{
138
+ ancestry: "ancestry";
139
+ squash: "squash";
140
+ rebase: "rebase";
141
+ }>>;
142
+ evidence: z.ZodArray<z.ZodString>;
143
+ pr: z.ZodOptional<z.ZodNumber>;
144
+ }, z.core.$strict>;
145
+ }, z.core.$strict>>;
146
+ provenance: z.ZodObject<{
147
+ source: z.ZodString;
148
+ confidence: z.ZodNumber;
149
+ evidence: z.ZodDefault<z.ZodArray<z.ZodString>>;
150
+ last_verified: z.ZodOptional<z.ZodString>;
151
+ }, z.core.$strip>;
152
+ }, z.core.$strict>;
153
+ export type Workspace = z.infer<typeof WorkspaceSchema>;
154
+ /** One record per machine: the id derives from the machine id, so a re-snapshot
155
+ * UPDATES the machine's record and two machines can never collide on a file. */
156
+ export declare function workspaceId(machineId: string): string;
157
+ /** Path-free worktree handle. */
158
+ export declare function worktreeId(path: string): string;
159
+ /** The same observation, published under `publish`: `branches` drops every worktree path
160
+ * (the default), `full` keeps them. Pure, so a caller that already took a live snapshot
161
+ * (paths included, for its own display) can publish it without re-running git. */
162
+ export declare function withPublishMode(record: Workspace, publish: "full" | "branches"): Workspace;
163
+ /** True when two snapshots of the same machine describe the same workspace, ignoring the
164
+ * observation stamps — so an idle machine's hook does not commit a new record per
165
+ * checkout. Provenance is constant per build and is compared too. */
166
+ export declare function sameWorkspaceContent(a: Workspace, b: Workspace): boolean;
167
+ export interface AggregateOptions {
168
+ /** Records older than this many days are reported as unverified. */
169
+ staleAfterDays?: number;
170
+ now?: Date;
171
+ }
172
+ export interface WorktreeRow {
173
+ machine: string;
174
+ worktree_id: string;
175
+ /** null in `branches` publish mode. */
176
+ path: string | null;
177
+ branch: string | null;
178
+ head: string;
179
+ dirty: boolean | null;
180
+ locked: boolean;
181
+ prunable: boolean;
182
+ last_commit_at: string | null;
183
+ seen_at: string;
184
+ unverified: boolean;
185
+ }
186
+ export interface BranchRow {
187
+ name: string;
188
+ /** Machine labels that hold this branch locally. */
189
+ machines: string[];
190
+ /** Machine labels with a worktree checked out on it. */
191
+ worktree_on: string[];
192
+ /** Machine labels whose worktree on it has uncommitted changes. */
193
+ dirty_on: string[];
194
+ /** Distinct heads across machines; more than one means the local branches diverged. */
195
+ heads: string[];
196
+ is_default: boolean;
197
+ upstream: string | null;
198
+ upstream_gone: boolean;
199
+ ahead: number | null;
200
+ behind: number | null;
201
+ last_commit_at: string | null;
202
+ merged: MergedVerdict;
203
+ /** Machine labels whose record is older than the staleness window. */
204
+ unverified_on: string[];
205
+ action: string;
206
+ }
207
+ export declare const DEFAULT_STALE_AFTER_DAYS = 7;
208
+ export declare function isUnverified(record: Pick<Workspace, "observed_at">, opts?: AggregateOptions): boolean;
209
+ /** Same machine id → the newest observation wins; a stale duplicate never shadows a fresh one. */
210
+ export declare function latestPerMachine(records: readonly Workspace[]): Workspace[];
211
+ export declare function worktreeRows(records: readonly Workspace[], opts?: AggregateOptions): WorktreeRow[];
212
+ /** The recommendation rules from docs/workspace-ledger.md — deterministic text an agent
213
+ * or a human reads; nothing executes it. */
214
+ export declare function recommendAction(row: Omit<BranchRow, "action">, opts?: AggregateOptions): string;
215
+ export declare function branchRows(records: readonly Workspace[], opts?: AggregateOptions): BranchRow[];
216
+ /** "2h ago" / "9d ago" for the SEEN column. */
217
+ export declare function ago(iso: string, now?: Date): string;
218
+ export interface PruneStep {
219
+ branch: string;
220
+ head: string;
221
+ /** Worktree checked out on the branch, when one exists and can be removed first. */
222
+ worktree: {
223
+ id: string;
224
+ path: string | null;
225
+ } | null;
226
+ commands: string[];
227
+ why: string;
228
+ /** How the merge was proven; squash and rebase merges are invisible to `git branch -d`. */
229
+ method: MergedVerdict["method"];
230
+ /** Ignored files in the worktree that `git worktree remove` deletes without asking (local
231
+ * plan only, read live; never stored). `shown` is bounded, `total` counts all entries. */
232
+ ignored?: {
233
+ shown: string[];
234
+ total: number;
235
+ };
236
+ }
237
+ /** POSIX shell quoting for one token of a PRINTED command (never executed through a shell
238
+ * here: execution uses argv arrays). Tokens made only of characters no shell treats
239
+ * specially stay bare; anything else is single-quoted with `'` escaped as `'\''`, which is
240
+ * also valid in Git Bash for Windows paths. */
241
+ export declare function shellQuote(token: string): string;
242
+ export interface PrunePlan {
243
+ /** Executable on this machine (live record). */
244
+ local: PruneStep[];
245
+ /** Display-only, keyed by machine label (stored records). */
246
+ others: Record<string, PruneStep[]>;
247
+ /** Branches this machine holds that were considered and left alone, with the reason. */
248
+ skipped: Array<{
249
+ branch: string;
250
+ reason: string;
251
+ }>;
252
+ }
253
+ /** Why a branch must not be pruned, or null when it may. The rules are the documented ones:
254
+ * proven merged, not the default branch, worktree (if any) clean, unlocked and present. */
255
+ export declare function pruneRefusal(b: WorkspaceBranch, wt: WorkspaceWorktree | undefined): string | null;
256
+ export declare function planPrune(live: Workspace, others: readonly Workspace[]): PrunePlan;