@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,93 @@
|
|
|
1
|
+
import { type WorkspacesConfig } from "../core/config.js";
|
|
2
|
+
import { type MachineIdentity } from "../core/machine.js";
|
|
3
|
+
import { branchRows, worktreeRows, type BranchRow, type PrunePlan, type PruneStep, type Workspace, type WorktreeRow } from "../core/workspace.js";
|
|
4
|
+
import type { HunchStore } from "../store/hunchStore.js";
|
|
5
|
+
export interface LedgerView {
|
|
6
|
+
machine: MachineIdentity;
|
|
7
|
+
/** This machine, live (paths included; never written). */
|
|
8
|
+
live: Workspace;
|
|
9
|
+
/** live + every OTHER machine's stored record. */
|
|
10
|
+
records: Workspace[];
|
|
11
|
+
config: WorkspacesConfig;
|
|
12
|
+
}
|
|
13
|
+
export declare function workspaceLedgerView(store: HunchStore, root: string, opts?: {
|
|
14
|
+
fetch?: boolean;
|
|
15
|
+
}): LedgerView;
|
|
16
|
+
export type SnapshotOutcome = {
|
|
17
|
+
status: "off";
|
|
18
|
+
} | {
|
|
19
|
+
status: "dry-run";
|
|
20
|
+
record: Workspace;
|
|
21
|
+
} | {
|
|
22
|
+
status: "no-home";
|
|
23
|
+
record: Workspace;
|
|
24
|
+
} | {
|
|
25
|
+
status: "unchanged";
|
|
26
|
+
record: Workspace;
|
|
27
|
+
previous: Workspace;
|
|
28
|
+
}
|
|
29
|
+
/** The OTHER memory home already holds a record with this machine's id (an old public
|
|
30
|
+
* copy, or a record someone else wrote under this id): the store refuses a twin, and so
|
|
31
|
+
* do we — `hunch workspaces forget <id>` removes the stale copy. */
|
|
32
|
+
| {
|
|
33
|
+
status: "collision";
|
|
34
|
+
record: Workspace;
|
|
35
|
+
reason: string;
|
|
36
|
+
} | {
|
|
37
|
+
status: "written";
|
|
38
|
+
record: Workspace;
|
|
39
|
+
home: "private" | "public";
|
|
40
|
+
flushed: "pushed" | "committed" | null;
|
|
41
|
+
};
|
|
42
|
+
/** Record this machine's snapshot. Honors `workspaces.publish`, skips a write when the
|
|
43
|
+
* content is unchanged and the stored record is under a day old (an idle machine's hooks
|
|
44
|
+
* must not commit a record per checkout), and reports exactly what happened. */
|
|
45
|
+
export declare function recordWorkspaceSnapshot(store: HunchStore, root: string, opts?: {
|
|
46
|
+
fetch?: boolean;
|
|
47
|
+
dryRun?: boolean;
|
|
48
|
+
live?: Workspace;
|
|
49
|
+
}): SnapshotOutcome;
|
|
50
|
+
/** Whether a snapshot could land anywhere on this root — used to skip work that would
|
|
51
|
+
* write nothing. */
|
|
52
|
+
export declare function snapshotHasHome(store: HunchStore, root: string): boolean;
|
|
53
|
+
export declare function padTable(header: string[], rows: string[][]): string;
|
|
54
|
+
export declare function renderWorktreeTable(view: LedgerView, rows: WorktreeRow[], now?: Date): string;
|
|
55
|
+
export declare function describeUpstream(r: BranchRow): string;
|
|
56
|
+
export declare function renderBranchTable(view: LedgerView, rows: BranchRow[]): string;
|
|
57
|
+
/** One line for `hunch now` / `hunch_now`, from STORED records only (no git, so the hot
|
|
58
|
+
* view stays fast); null when memory holds no workspace record. */
|
|
59
|
+
export declare function workspaceSummaryLine(records: readonly Workspace[], config: WorkspacesConfig, now?: Date): string | null;
|
|
60
|
+
export { branchRows, worktreeRows };
|
|
61
|
+
/** This machine's plan: the pure plan from the live record, then two live checks per step.
|
|
62
|
+
* A step `git branch -d` would refuse is moved to `skipped` BEFORE anything runs (removing
|
|
63
|
+
* the worktree and then failing the branch delete would half-apply the step), and a
|
|
64
|
+
* worktree's ignored files — which `git worktree remove` deletes without asking — are
|
|
65
|
+
* attached so the plan and the confirmation name them. */
|
|
66
|
+
export declare function prunePlanFor(view: LedgerView, root: string): PrunePlan;
|
|
67
|
+
/** Why `git branch -d -- <branch>` would refuse right now, or null when it would delete.
|
|
68
|
+
* Mirrors git's own rule (builtin/branch.c `branch_merged`): the branch head must be an
|
|
69
|
+
* ancestor of its upstream when one is configured AND resolves, otherwise of HEAD of the
|
|
70
|
+
* worktree the command runs in (the main worktree). Squash and rebase merges never pass it
|
|
71
|
+
* once the upstream is gone or unset, so such a step is reported, not half-applied. The
|
|
72
|
+
* branch name is matched in JS; only refs git printed and SHAs reach git as arguments. */
|
|
73
|
+
export declare function branchDeleteRefusal(root: string, step: Pick<PruneStep, "branch" | "head" | "method">): string | null;
|
|
74
|
+
export interface PruneResult {
|
|
75
|
+
step: PruneStep;
|
|
76
|
+
/** `skipped`: a live precondition failed before anything ran — the step is untouched. */
|
|
77
|
+
outcome: "deleted" | "skipped" | "failed";
|
|
78
|
+
detail: string;
|
|
79
|
+
}
|
|
80
|
+
/** Execute the local steps of a plan. Each command is a fixed argv; the branch name was
|
|
81
|
+
* validated by the record schema and is passed after `--`; the worktree path comes from
|
|
82
|
+
* `git worktree list` on this machine. `git branch -d`'s own precondition is re-checked
|
|
83
|
+
* BEFORE the worktree is removed, so a step either runs whole or not at all. A failure
|
|
84
|
+
* stops that step, never the others. */
|
|
85
|
+
export declare function applyPrune(root: string, steps: readonly PruneStep[]): PruneResult[];
|
|
86
|
+
export declare function describeIgnored(ignored: NonNullable<PruneStep["ignored"]>): string;
|
|
87
|
+
/** The confirmation question for `prune --apply`: counts, and — because `git worktree
|
|
88
|
+
* remove` deletes ignored files (.env, build output) without asking — every worktree's
|
|
89
|
+
* ignored paths by name. */
|
|
90
|
+
export declare function pruneConfirmQuestion(view: LedgerView, plan: PrunePlan): string;
|
|
91
|
+
/** Interactive yes/no; false when stdin is not a terminal (the caller then needs --yes). */
|
|
92
|
+
export declare function confirmPrune(question: string): Promise<boolean>;
|
|
93
|
+
export declare function renderPrunePlan(view: LedgerView, plan: PrunePlan): string;
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace ledger wiring (docs/workspace-ledger.md, Phase 2): the ONE code path every
|
|
3
|
+
* surface uses to read the ledger (CLI `workspaces` / `branches`, the `hunch_workspaces`
|
|
4
|
+
* MCP tool, `hunch now`, `doctor`) and to record this machine's snapshot (CLI `snapshot`,
|
|
5
|
+
* `hunch worktree`, the git hooks, and the MCP server's session-start refresh).
|
|
6
|
+
*
|
|
7
|
+
* This machine is always read LIVE from git and never from a stored record; stored
|
|
8
|
+
* records (other machines) are display-only. A snapshot writes through the same capture
|
|
9
|
+
* funnel as every other record: the overlay when one is configured, the public .hunch/
|
|
10
|
+
* only when `workspaces.publish_public` opts in, nothing otherwise.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
13
|
+
import { createInterface } from "node:readline";
|
|
14
|
+
import { foreignRepoEnv, mainWorktreeRoot } from "../extractors/git.js";
|
|
15
|
+
import { hunchPaths } from "../core/paths.js";
|
|
16
|
+
import { readConfig, workspacesConfig } from "../core/config.js";
|
|
17
|
+
import { loadOrCreateMachine } from "../core/machine.js";
|
|
18
|
+
import { CONTROL_CHARS, ago, branchRows, isSafeBranchName, latestPerMachine, isUnverified, planPrune, sameWorkspaceContent, withPublishMode, worktreeRows, } from "../core/workspace.js";
|
|
19
|
+
import { ignoredPaths, snapshotWorkspace } from "../extractors/workspaces.js";
|
|
20
|
+
import { flushCapture } from "./sync.js";
|
|
21
|
+
export function workspaceLedgerView(store, root, opts = {}) {
|
|
22
|
+
const machine = loadOrCreateMachine();
|
|
23
|
+
const config = workspacesConfig(readConfig(hunchPaths(root)));
|
|
24
|
+
const live = snapshotWorkspace(root, { machine, publish: "full", fetch: !!opts.fetch });
|
|
25
|
+
const others = store.recs("workspaces").filter((r) => r.machine.id !== machine.id);
|
|
26
|
+
return { machine, live, records: [live, ...others], config };
|
|
27
|
+
}
|
|
28
|
+
/** Record this machine's snapshot. Honors `workspaces.publish`, skips a write when the
|
|
29
|
+
* content is unchanged and the stored record is under a day old (an idle machine's hooks
|
|
30
|
+
* must not commit a record per checkout), and reports exactly what happened. */
|
|
31
|
+
export function recordWorkspaceSnapshot(store, root, opts = {}) {
|
|
32
|
+
const config = workspacesConfig(readConfig(hunchPaths(root)));
|
|
33
|
+
if (config.publish === "off")
|
|
34
|
+
return { status: "off" };
|
|
35
|
+
const machine = loadOrCreateMachine();
|
|
36
|
+
// A caller that already took a live snapshot (a ledger read) publishes THAT observation
|
|
37
|
+
// rather than paying for a second pass over git.
|
|
38
|
+
const record = opts.live && !opts.fetch
|
|
39
|
+
? withPublishMode(opts.live, config.publish)
|
|
40
|
+
: snapshotWorkspace(root, { machine, publish: config.publish, fetch: !!opts.fetch });
|
|
41
|
+
if (opts.dryRun)
|
|
42
|
+
return { status: "dry-run", record };
|
|
43
|
+
const isPrivate = store.hasPrivate;
|
|
44
|
+
if (!isPrivate && !config.publish_public)
|
|
45
|
+
return { status: "no-home", record };
|
|
46
|
+
const previous = store.getRec("workspaces", record.id);
|
|
47
|
+
if (previous && Date.now() - Date.parse(previous.observed_at) < 86_400_000 && sameWorkspaceContent(previous, record)) {
|
|
48
|
+
return { status: "unchanged", record, previous };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
store.putCapture("workspaces", record, isPrivate);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const reason = error.message;
|
|
55
|
+
if (/already exists in the other memory home/.test(reason))
|
|
56
|
+
return { status: "collision", record, reason };
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
const flushed = flushCapture(store, hunchPaths(root).hunch, isPrivate, `hunch: workspace snapshot ${machine.label}`);
|
|
60
|
+
return { status: "written", record, home: isPrivate ? "private" : "public", flushed };
|
|
61
|
+
}
|
|
62
|
+
/** Whether a snapshot could land anywhere on this root — used to skip work that would
|
|
63
|
+
* write nothing. */
|
|
64
|
+
export function snapshotHasHome(store, root) {
|
|
65
|
+
const config = workspacesConfig(readConfig(hunchPaths(root)));
|
|
66
|
+
return config.publish !== "off" && (store.hasPrivate || config.publish_public);
|
|
67
|
+
}
|
|
68
|
+
// ---- rendering (shared by the CLI and the MCP tool) -----------------------------------------
|
|
69
|
+
export function padTable(header, rows) {
|
|
70
|
+
const all = [header, ...rows];
|
|
71
|
+
const widths = header.map((_, i) => Math.max(...all.map((r) => (r[i] ?? "").length)));
|
|
72
|
+
return all.map((r) => r.map((c, i) => (i === r.length - 1 ? c ?? "" : (c ?? "").padEnd(widths[i]))).join(" ").trimEnd()).join("\n");
|
|
73
|
+
}
|
|
74
|
+
export function renderWorktreeTable(view, rows, now = new Date()) {
|
|
75
|
+
const table = padTable(["MACHINE", "WORKTREE", "BRANCH", "DIRTY", "LAST COMMIT", "SEEN"], rows.map((r) => [
|
|
76
|
+
r.machine + (r.machine === view.machine.label ? " (this)" : ""),
|
|
77
|
+
r.path ?? "yes",
|
|
78
|
+
r.branch ?? `(detached ${r.head.slice(0, 10)})`,
|
|
79
|
+
r.dirty === null ? "?" : r.dirty ? "yes" : "-",
|
|
80
|
+
r.last_commit_at ? ago(r.last_commit_at, now) : "-",
|
|
81
|
+
(r.machine === view.machine.label ? "live" : ago(r.seen_at, now)) + (r.unverified ? " (unverified)" : "") + (r.prunable ? " (path missing)" : "") + (r.locked ? " (locked)" : ""),
|
|
82
|
+
]));
|
|
83
|
+
return `${table}\n\n${rows.length} worktree(s) · this machine is ${view.machine.label} · ${view.records.length - 1} other machine(s) in memory`;
|
|
84
|
+
}
|
|
85
|
+
export function describeUpstream(r) {
|
|
86
|
+
if (r.upstream === null)
|
|
87
|
+
return "never pushed";
|
|
88
|
+
if (r.upstream_gone)
|
|
89
|
+
return "gone";
|
|
90
|
+
return [r.ahead ? `ahead ${r.ahead}` : "", r.behind ? `behind ${r.behind}` : ""].filter(Boolean).join(", ") || "synced";
|
|
91
|
+
}
|
|
92
|
+
export function renderBranchTable(view, rows) {
|
|
93
|
+
const table = padTable(["BRANCH", "MACHINES", "WORKTREE", "UPSTREAM", "MERGED", "ACTION"], rows.map((r) => [
|
|
94
|
+
r.name,
|
|
95
|
+
r.machines.join(","),
|
|
96
|
+
r.worktree_on.length ? r.worktree_on.join(",") + (r.dirty_on.length ? " (dirty)" : "") : "-",
|
|
97
|
+
describeUpstream(r),
|
|
98
|
+
r.merged.status === "merged" ? `yes (${r.merged.method}${r.merged.pr ? `, PR #${r.merged.pr}` : ""})` : r.merged.status === "unmerged" ? "no" : r.merged.status === "no-commits" ? "no commits" : "unknown",
|
|
99
|
+
r.action,
|
|
100
|
+
]));
|
|
101
|
+
const deletable = rows.filter((r) => r.action.startsWith("delete local")).length;
|
|
102
|
+
const warn = view.live.default_branch === null ? "\n ⚠ no default branch resolved (origin/HEAD, origin/main|master, main|master) — merge verdicts are unknown" : "";
|
|
103
|
+
return `${table}\n\n${rows.length} branch(es) · ${deletable} deletable · this machine is ${view.machine.label}${warn}`;
|
|
104
|
+
}
|
|
105
|
+
/** One line for `hunch now` / `hunch_now`, from STORED records only (no git, so the hot
|
|
106
|
+
* view stays fast); null when memory holds no workspace record. */
|
|
107
|
+
export function workspaceSummaryLine(records, config, now = new Date()) {
|
|
108
|
+
const latest = latestPerMachine(records);
|
|
109
|
+
if (!latest.length)
|
|
110
|
+
return null;
|
|
111
|
+
const opts = { staleAfterDays: config.stale_after_days, now };
|
|
112
|
+
const worktrees = worktreeRows(latest, opts);
|
|
113
|
+
const branches = branchRows(latest, opts);
|
|
114
|
+
const unverified = latest.filter((r) => isUnverified(r, opts)).length;
|
|
115
|
+
const deletable = branches.filter((b) => b.action.startsWith("delete local")).length;
|
|
116
|
+
const dirty = worktrees.filter((w) => w.dirty === true).length;
|
|
117
|
+
return `🗂 Workspaces in memory: ${latest.length} machine(s)${unverified ? ` (${unverified} unverified)` : ""} · ${worktrees.length} worktree(s)${dirty ? ` (${dirty} dirty)` : ""} · ${branches.length} branch(es), ${deletable} deletable — \`hunch branches\` for the verdicts`;
|
|
118
|
+
}
|
|
119
|
+
export { branchRows, worktreeRows };
|
|
120
|
+
// ---- prune (Phase 3) ------------------------------------------------------------------------
|
|
121
|
+
//
|
|
122
|
+
// `--apply` acts on THIS machine only, from a snapshot taken moments ago (never a stored
|
|
123
|
+
// record), with `git worktree remove` (no --force) and `git branch -d` (no -D), so git itself
|
|
124
|
+
// re-checks "clean" and "merged" as a second line of defense. Nothing here touches a remote
|
|
125
|
+
// or another machine; their commands are printed for a human to run there.
|
|
126
|
+
/** This machine's plan: the pure plan from the live record, then two live checks per step.
|
|
127
|
+
* A step `git branch -d` would refuse is moved to `skipped` BEFORE anything runs (removing
|
|
128
|
+
* the worktree and then failing the branch delete would half-apply the step), and a
|
|
129
|
+
* worktree's ignored files — which `git worktree remove` deletes without asking — are
|
|
130
|
+
* attached so the plan and the confirmation name them. */
|
|
131
|
+
export function prunePlanFor(view, root) {
|
|
132
|
+
const plan = planPrune(view.live, view.records);
|
|
133
|
+
const local = [];
|
|
134
|
+
for (const step of plan.local) {
|
|
135
|
+
const refusal = branchDeleteRefusal(root, step);
|
|
136
|
+
if (refusal) {
|
|
137
|
+
plan.skipped.push({ branch: step.branch, reason: refusal });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (step.worktree?.path) {
|
|
141
|
+
const ignored = ignoredPaths(step.worktree.path);
|
|
142
|
+
if (ignored === null) {
|
|
143
|
+
plan.skipped.push({ branch: step.branch, reason: "the worktree's ignored files could not be listed" });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (ignored.total)
|
|
147
|
+
step.ignored = ignored;
|
|
148
|
+
}
|
|
149
|
+
local.push(step);
|
|
150
|
+
}
|
|
151
|
+
plan.local = local;
|
|
152
|
+
return plan;
|
|
153
|
+
}
|
|
154
|
+
function gitEnv() {
|
|
155
|
+
return { ...foreignRepoEnv(process.env), GIT_OPTIONAL_LOCKS: "0" };
|
|
156
|
+
}
|
|
157
|
+
const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
158
|
+
/** Why `git branch -d -- <branch>` would refuse right now, or null when it would delete.
|
|
159
|
+
* Mirrors git's own rule (builtin/branch.c `branch_merged`): the branch head must be an
|
|
160
|
+
* ancestor of its upstream when one is configured AND resolves, otherwise of HEAD of the
|
|
161
|
+
* worktree the command runs in (the main worktree). Squash and rebase merges never pass it
|
|
162
|
+
* once the upstream is gone or unset, so such a step is reported, not half-applied. The
|
|
163
|
+
* branch name is matched in JS; only refs git printed and SHAs reach git as arguments. */
|
|
164
|
+
export function branchDeleteRefusal(root, step) {
|
|
165
|
+
const main = mainWorktreeRoot(root);
|
|
166
|
+
const env = gitEnv();
|
|
167
|
+
const read = (args) => {
|
|
168
|
+
try {
|
|
169
|
+
return execFileSync("git", args, { cwd: main, env, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const unchecked = "git branch -d precondition could not be checked; nothing was changed";
|
|
176
|
+
const refs = read(["for-each-ref", "--format=%(refname)%00%(objectname)%00%(upstream)", "refs/heads/"]);
|
|
177
|
+
if (refs === null)
|
|
178
|
+
return unchecked;
|
|
179
|
+
const entry = refs.split("\n").map((line) => line.split("\0")).find(([ref]) => ref === `refs/heads/${step.branch}`);
|
|
180
|
+
if (!entry)
|
|
181
|
+
return "branch no longer exists";
|
|
182
|
+
const [, head = "", upstream = ""] = entry;
|
|
183
|
+
if (head !== step.head)
|
|
184
|
+
return `branch moved since the snapshot (now ${head.slice(0, 12)})`;
|
|
185
|
+
let reference = null;
|
|
186
|
+
let referenceName = "HEAD";
|
|
187
|
+
if (upstream.startsWith("refs/")) {
|
|
188
|
+
const resolved = read(["rev-parse", "--verify", "-q", "--end-of-options", `${upstream}^{commit}`]);
|
|
189
|
+
if (resolved && SHA.test(resolved)) {
|
|
190
|
+
reference = resolved;
|
|
191
|
+
referenceName = upstream.replace(/^refs\/(?:remotes|heads)\//, "");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!reference) {
|
|
195
|
+
const resolved = read(["rev-parse", "--verify", "-q", "--end-of-options", "HEAD^{commit}"]);
|
|
196
|
+
if (!resolved || !SHA.test(resolved))
|
|
197
|
+
return "git branch -d would refuse: the main worktree's HEAD does not resolve; delete manually after checking";
|
|
198
|
+
reference = resolved;
|
|
199
|
+
}
|
|
200
|
+
const r = spawnSync("git", ["merge-base", "--is-ancestor", step.head, reference], { cwd: main, env, timeout: 30_000, stdio: "ignore" });
|
|
201
|
+
if (r.error || r.status === null || (r.status !== 0 && r.status !== 1))
|
|
202
|
+
return unchecked;
|
|
203
|
+
if (r.status === 0)
|
|
204
|
+
return null;
|
|
205
|
+
return step.method === "squash" || step.method === "rebase"
|
|
206
|
+
? `${step.method}-merged: git branch -d would refuse (not merged into ${referenceName}); delete manually after checking`
|
|
207
|
+
: `git branch -d would refuse: not merged into ${referenceName}; update it, or delete manually after checking`;
|
|
208
|
+
}
|
|
209
|
+
/** Execute the local steps of a plan. Each command is a fixed argv; the branch name was
|
|
210
|
+
* validated by the record schema and is passed after `--`; the worktree path comes from
|
|
211
|
+
* `git worktree list` on this machine. `git branch -d`'s own precondition is re-checked
|
|
212
|
+
* BEFORE the worktree is removed, so a step either runs whole or not at all. A failure
|
|
213
|
+
* stops that step, never the others. */
|
|
214
|
+
export function applyPrune(root, steps) {
|
|
215
|
+
const main = mainWorktreeRoot(root);
|
|
216
|
+
const env = gitEnv();
|
|
217
|
+
const git = (args) => execFileSync("git", args, { cwd: main, env, encoding: "utf8", timeout: 60_000, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
218
|
+
const results = [];
|
|
219
|
+
for (const step of steps) {
|
|
220
|
+
if (!isSafeBranchName(step.branch)) {
|
|
221
|
+
results.push({ step, outcome: "failed", detail: "refused: unsafe branch name" });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const refusal = branchDeleteRefusal(root, step);
|
|
225
|
+
if (refusal) {
|
|
226
|
+
results.push({ step, outcome: "skipped", detail: `skipped (worktree and branch kept): ${refusal}` });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const detail = [];
|
|
231
|
+
if (step.worktree?.path) {
|
|
232
|
+
git(["worktree", "remove", "--", step.worktree.path]);
|
|
233
|
+
detail.push(`removed worktree ${step.worktree.path}`);
|
|
234
|
+
}
|
|
235
|
+
else if (step.worktree) {
|
|
236
|
+
results.push({ step, outcome: "failed", detail: "refused: the worktree's path is not known on this machine" });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
git(["branch", "-d", "--", step.branch]);
|
|
240
|
+
detail.push(`deleted branch ${step.branch} (was ${step.head.slice(0, 12)})`);
|
|
241
|
+
results.push({ step, outcome: "deleted", detail: detail.join("; ") });
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
const stderr = error.stderr?.toString().trim().split("\n")[0] ?? error.message;
|
|
245
|
+
results.push({ step, outcome: "failed", detail: `git refused: ${stderr}` });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return results;
|
|
249
|
+
}
|
|
250
|
+
/** A file name is repository content a terminal would interpret: control characters are
|
|
251
|
+
* shown escaped, never emitted. */
|
|
252
|
+
function printable(text) {
|
|
253
|
+
return text.replace(new RegExp(CONTROL_CHARS.source, "g"), (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
254
|
+
}
|
|
255
|
+
export function describeIgnored(ignored) {
|
|
256
|
+
const more = ignored.total - ignored.shown.length;
|
|
257
|
+
return `${ignored.total} ignored path(s) in the worktree: ${ignored.shown.map(printable).join(", ")}${more > 0 ? `, … and ${more} more` : ""}`;
|
|
258
|
+
}
|
|
259
|
+
/** The confirmation question for `prune --apply`: counts, and — because `git worktree
|
|
260
|
+
* remove` deletes ignored files (.env, build output) without asking — every worktree's
|
|
261
|
+
* ignored paths by name. */
|
|
262
|
+
export function pruneConfirmQuestion(view, plan) {
|
|
263
|
+
const worktrees = plan.local.filter((s) => s.worktree).length;
|
|
264
|
+
const details = plan.local.filter((s) => s.ignored).map((s) => ` ${s.branch}: removing its worktree also deletes ${describeIgnored(s.ignored)}`);
|
|
265
|
+
return [...details, `Delete ${plan.local.length} branch(es)${worktrees ? ` and remove ${worktrees} worktree(s)` : ""} on ${view.machine.label}?`].join("\n");
|
|
266
|
+
}
|
|
267
|
+
/** Interactive yes/no; false when stdin is not a terminal (the caller then needs --yes). */
|
|
268
|
+
export async function confirmPrune(question) {
|
|
269
|
+
if (!process.stdin.isTTY)
|
|
270
|
+
return false;
|
|
271
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
272
|
+
try {
|
|
273
|
+
const answer = await new Promise((resolve) => rl.question(`${question} [y/N] `, resolve));
|
|
274
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
rl.close();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
export function renderPrunePlan(view, plan) {
|
|
281
|
+
const L = [];
|
|
282
|
+
L.push(`This machine (${view.machine.label}) — ${plan.local.length} branch(es) provably merged and safe to delete:`);
|
|
283
|
+
if (!plan.local.length)
|
|
284
|
+
L.push(" (nothing)");
|
|
285
|
+
for (const step of plan.local) {
|
|
286
|
+
L.push(` ${step.branch} — ${step.why}`);
|
|
287
|
+
for (const c of step.commands)
|
|
288
|
+
L.push(` ${c}`);
|
|
289
|
+
if (step.ignored)
|
|
290
|
+
L.push(` ⚠ also deletes ${describeIgnored(step.ignored)}`);
|
|
291
|
+
}
|
|
292
|
+
if (plan.skipped.length) {
|
|
293
|
+
L.push("", "Merged but left alone on this machine:");
|
|
294
|
+
for (const s of plan.skipped)
|
|
295
|
+
L.push(` ${s.branch} — ${s.reason}`);
|
|
296
|
+
}
|
|
297
|
+
for (const [label, steps] of Object.entries(plan.others)) {
|
|
298
|
+
const record = view.records.find((r) => r.machine.label === label);
|
|
299
|
+
const stale = record && isUnverified(record, { staleAfterDays: view.config.stale_after_days }) ? " (unverified — the record is old)" : "";
|
|
300
|
+
L.push("", `On ${label}${stale} — run there, from that machine's stored record (never executed from here):`);
|
|
301
|
+
for (const step of steps)
|
|
302
|
+
for (const c of step.commands)
|
|
303
|
+
L.push(` ${c}`);
|
|
304
|
+
}
|
|
305
|
+
return L.join("\n");
|
|
306
|
+
}
|
|
307
|
+
//# sourceMappingURL=workspaceLedger.js.map
|
package/dist/mcp/server.js
CHANGED
|
@@ -26,7 +26,9 @@ import { decisionId, findingId } from "../core/ids.js";
|
|
|
26
26
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
27
27
|
import { knownRepoDeps } from "../synthesis/tripwires.js";
|
|
28
28
|
import { refreshExistingGrounding } from "../integrations/providers.js";
|
|
29
|
-
import {
|
|
29
|
+
import { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
|
|
30
|
+
import { workspacesConfig } from "../core/config.js";
|
|
31
|
+
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatus, sameRemoteUrl, currentBranch } from "../extractors/git.js";
|
|
30
32
|
import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
|
|
31
33
|
import { withWriteLock } from "../serve/writelock.js";
|
|
32
34
|
import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
|
|
@@ -1231,6 +1233,12 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1231
1233
|
L.push(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
|
|
1232
1234
|
if (pendingReview > 0)
|
|
1233
1235
|
L.push("", `${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` auto-trusts them as advisory (new captures land trusted automatically).`);
|
|
1236
|
+
// Workspace ledger, from stored PUBLIC records only (same jurisdiction rule as the rest
|
|
1237
|
+
// of this view; no git, so the hot view stays fast). Machine labels are user-chosen
|
|
1238
|
+
// and the default is anonymous, so the line is publishable by construction.
|
|
1239
|
+
const ws = workspaceSummaryLine(store.json.loadAll("workspaces"), workspacesConfig(readConfig(hunchPaths(root))));
|
|
1240
|
+
if (ws)
|
|
1241
|
+
L.push("", ws);
|
|
1234
1242
|
const escalations = pendingEscalations(store.advisoryRecs("decisions"));
|
|
1235
1243
|
escalations.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
|
|
1236
1244
|
// liveness checked against the full store (repair-provenance reads the
|
|
@@ -1248,6 +1256,52 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1248
1256
|
}
|
|
1249
1257
|
return ok(L.join("\n"));
|
|
1250
1258
|
});
|
|
1259
|
+
// -- hunch_workspaces (the workspace ledger: worktrees + branches across machines) --
|
|
1260
|
+
// READ-ONLY by design (docs/workspace-ledger.md): an agent can see what is prunable but
|
|
1261
|
+
// can only act through the CLI, where a human confirms. This machine is read live from
|
|
1262
|
+
// git; other machines come from stored records, which are display-only.
|
|
1263
|
+
server.registerTool("hunch_workspaces", {
|
|
1264
|
+
title: "Worktrees and branches across machines",
|
|
1265
|
+
description: "The workspace ledger: which git worktrees are open on which machine, and every local branch with a deterministic verdict — merged (ancestry / squash / rebase), never pushed, upstream gone, dirty worktree — plus a recommended action per branch. Call this INSTEAD of running git branch / git worktree list / git log to answer 'what is open, what is stale, what can be deleted'. This machine is read live; other machines from memory (a machine older than the staleness window is marked unverified). Read-only: it never deletes anything. Not for design rationale (hunch_why) or code structure (hunch_structure).",
|
|
1266
|
+
inputSchema: {
|
|
1267
|
+
view: z.enum(["inventory", "branches"]).optional().describe("inventory = one row per worktree (default); branches = one row per branch with its verdict and action."),
|
|
1268
|
+
machine: z.string().optional().describe("Only this machine label."),
|
|
1269
|
+
branch: z.string().optional().describe("Only this branch name."),
|
|
1270
|
+
merged_only: z.boolean().optional().describe("branches view: only branches proven merged."),
|
|
1271
|
+
},
|
|
1272
|
+
}, async ({ view, machine, branch, merged_only }) => {
|
|
1273
|
+
const ledger = workspaceLedgerView(store, root);
|
|
1274
|
+
// Publish the observation this read just took, so the machines that ask about the
|
|
1275
|
+
// ledger are also the machines visible IN it — the one moment the data provably
|
|
1276
|
+
// matters, with no timer and no child process (a detached snapshot child held a
|
|
1277
|
+
// Windows clone directory open and broke an unrelated test's teardown). The git
|
|
1278
|
+
// hooks remain the normal path; this covers a host that has none yet. Best effort:
|
|
1279
|
+
// the read never fails because memory could not be written.
|
|
1280
|
+
// HUNCH_WORKSPACE_REFRESH=0 opts out entirely.
|
|
1281
|
+
if (process.env.HUNCH_WORKSPACE_REFRESH !== "0" && snapshotHasHome(store, root)) {
|
|
1282
|
+
try {
|
|
1283
|
+
recordWorkspaceSnapshot(store, root, { live: ledger.live });
|
|
1284
|
+
}
|
|
1285
|
+
catch { /* the ledger is a side effect of the read, never its blocker */ }
|
|
1286
|
+
}
|
|
1287
|
+
const opts = { staleAfterDays: ledger.config.stale_after_days };
|
|
1288
|
+
if (view === "branches") {
|
|
1289
|
+
let rows = branchRows(ledger.records, opts);
|
|
1290
|
+
if (machine)
|
|
1291
|
+
rows = rows.filter((r) => r.machines.includes(machine));
|
|
1292
|
+
if (branch)
|
|
1293
|
+
rows = rows.filter((r) => r.name === branch);
|
|
1294
|
+
if (merged_only)
|
|
1295
|
+
rows = rows.filter((r) => r.merged.status === "merged");
|
|
1296
|
+
return { content: [{ type: "text", text: renderBranchTable(ledger, rows) }], structuredContent: { machine: ledger.machine.label, branches: rows } };
|
|
1297
|
+
}
|
|
1298
|
+
let rows = worktreeRows(ledger.records, opts);
|
|
1299
|
+
if (machine)
|
|
1300
|
+
rows = rows.filter((r) => r.machine === machine);
|
|
1301
|
+
if (branch)
|
|
1302
|
+
rows = rows.filter((r) => r.branch === branch);
|
|
1303
|
+
return { content: [{ type: "text", text: renderWorktreeTable(ledger, rows) }], structuredContent: { machine: ledger.machine.label, worktrees: rows } };
|
|
1304
|
+
});
|
|
1251
1305
|
// -- hunch_escalations (the inline "ask the human" surface) -----------------
|
|
1252
1306
|
// Captured memory auto-trusts; this returns ONLY what the graph can't resolve
|
|
1253
1307
|
// itself, framed as questions to raise in conversation: topic conflicts, plus the
|
|
@@ -2084,8 +2138,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2084
2138
|
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
|
|
2085
2139
|
if (!files.length)
|
|
2086
2140
|
return ok(`VERDICT: ✅ PASS — no changed files in ${scope}.`);
|
|
2087
|
-
const
|
|
2088
|
-
const report = store.buildCheckReport(files, diff, { strict: true, lastChange: (f) => lastChangeDate(f, root) });
|
|
2141
|
+
const gate = commit ? commitGateDiff(commit, root) : base ? rangeGateDiff(base, root) : working ? workingGateDiff(root) : stagedGateDiff(root);
|
|
2142
|
+
const report = store.buildCheckReport(files, gate.diff, { strict: true, lastChange: (f) => lastChangeDate(f, root), diffStatus: gate });
|
|
2089
2143
|
const v = verdict(report);
|
|
2090
2144
|
const head = v === "block"
|
|
2091
2145
|
? "VERDICT: ⛔ BLOCK — a recorded guard requires review; inspect the cited scope and evidence below before merge."
|
|
@@ -2127,8 +2181,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2127
2181
|
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
|
|
2128
2182
|
if (!files.length)
|
|
2129
2183
|
return ok(`No changed files in ${scope}.`);
|
|
2130
|
-
const
|
|
2131
|
-
return ok(renderImpact(store.prImpact(files, diff), scope));
|
|
2184
|
+
const gate = commit ? commitGateDiff(commit, root) : base ? rangeGateDiff(base, root) : working ? workingGateDiff(root) : stagedGateDiff(root);
|
|
2185
|
+
return ok(renderImpact(store.prImpact(files, gate.diff, gate), scope));
|
|
2132
2186
|
}
|
|
2133
2187
|
catch (e) {
|
|
2134
2188
|
return err(`Failed to compute impact: ${e.message}`);
|
package/dist/serve/app.d.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* POST /nuryel/v1/write → writeState (under the partition's write lock)
|
|
14
14
|
* POST /nuryel/v1/subscribe → subscribeState
|
|
15
15
|
* POST /nuryel/v1/records → recordsState (by id, grants first)
|
|
16
|
+
* POST /nuryel/v1/mcp → the same verbs as nuryel_* tools over MCP streamable HTTP
|
|
17
|
+
* (src/serve/mcpHttp.ts), through the same dispatcher
|
|
16
18
|
* Request bodies are the contract's request schemas minus `schema` and `principal`.
|
|
17
19
|
*/
|
|
18
20
|
import { type Server } from "node:http";
|