@davesheffer/hunch 1.38.1 → 1.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +355 -55
- package/dist/cli/integrations.js +10 -0
- package/dist/cli/serve.js +1 -0
- package/dist/client/readOrCompute.d.ts +77 -0
- package/dist/client/readOrCompute.js +85 -0
- package/dist/client/state.d.ts +1 -0
- package/dist/client/state.js +1 -0
- package/dist/constitution/g2.d.ts +1 -0
- package/dist/constitution/service.js +8 -0
- package/dist/constitution/sourceMutation.js +23 -18
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +48 -5
- package/dist/core/changeProof.js +5 -1
- package/dist/core/checkreport.d.ts +7 -0
- package/dist/core/checkreport.js +20 -3
- package/dist/core/compare.js +3 -2
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +13 -0
- package/dist/core/machine.d.ts +20 -0
- package/dist/core/machine.js +101 -0
- package/dist/core/taskReportEvidence.js +6 -6
- package/dist/core/types.d.ts +67 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +256 -0
- package/dist/core/workspace.js +359 -0
- package/dist/extractors/diff.d.ts +34 -0
- package/dist/extractors/diff.js +147 -5
- package/dist/extractors/git.d.ts +40 -11
- package/dist/extractors/git.js +147 -43
- package/dist/extractors/helm.d.ts +17 -28
- package/dist/extractors/helm.js +12 -12
- package/dist/extractors/indexer.js +171 -7
- package/dist/extractors/k8sManifest.d.ts +59 -0
- package/dist/extractors/k8sManifest.js +507 -0
- package/dist/extractors/workspaces.d.ts +28 -0
- package/dist/extractors/workspaces.js +427 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.d.ts +27 -2
- package/dist/integrations/gitignore.js +103 -17
- package/dist/integrations/hooks.d.ts +63 -7
- package/dist/integrations/hooks.js +350 -38
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +93 -0
- package/dist/integrations/workspaceLedger.js +307 -0
- package/dist/mcp/server.js +59 -5
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +107 -92
- package/dist/serve/mcpHttp.d.ts +27 -0
- package/dist/serve/mcpHttp.js +95 -0
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +83 -35
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,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 {
|
|
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
|
|
108
|
-
const analysis = analyzeDiff(diff);
|
|
109
|
-
const
|
|
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 (
|
|
145
|
-
note("constraint-forbids", "unavailable", "The working diff
|
|
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) ?? []));
|
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,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;
|
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,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;
|