@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.
- package/dist/cli/index.js +240 -6
- 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/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/types.d.ts +66 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +234 -0
- package/dist/core/workspace.js +335 -0
- 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 +18 -0
- package/dist/extractors/workspaces.js +350 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/hooks.d.ts +2 -0
- package/dist/integrations/hooks.js +25 -0
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +73 -0
- package/dist/integrations/workspaceLedger.js +201 -0
- package/dist/mcp/server.js +54 -0
- 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/package.json +1 -1
- package/server.json +2 -2
package/dist/core/config.d.ts
CHANGED
|
@@ -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. */
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/types.js
CHANGED
|
@@ -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;
|