@davesheffer/hunch 1.38.0 → 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 +255 -9
- 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/taskRecord.js +6 -3
- package/dist/core/taskReport.d.ts +25 -2
- package/dist/core/taskReport.js +92 -18
- package/dist/core/taskReportEvidence.d.ts +4 -1
- package/dist/core/taskReportEvidence.js +5 -2
- package/dist/core/taskReportHook.d.ts +16 -3
- package/dist/core/taskReportHook.js +65 -13
- package/dist/core/taskTouched.d.ts +4 -0
- package/dist/core/taskTouched.js +30 -10
- 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/mcp/taskReportTools.d.ts +9 -0
- package/dist/mcp/taskReportTools.js +13 -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/package.json +1 -1
- package/server.json +2 -2
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { findRoot } from "./paths.js";
|
|
6
6
|
import { canonicalReportRoot } from "./taskReportPaths.js";
|
|
7
7
|
import { isCredentialFreeText } from "./types.js";
|
|
8
|
-
import { continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, startReportTask } from "./taskReport.js";
|
|
8
|
+
import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
|
|
9
9
|
import { reportSourceSnapshot } from "./taskReportEvidence.js";
|
|
10
10
|
import { renderTaskReport } from "./taskReportRender.js";
|
|
11
11
|
/** The exact task identity a native host prompt maps to. */
|
|
@@ -73,7 +73,14 @@ function identity(root, provider, event) {
|
|
|
73
73
|
return null;
|
|
74
74
|
if (!event.prompt_id)
|
|
75
75
|
return "legacy";
|
|
76
|
-
|
|
76
|
+
const id = promptTaskId(root, event.session_id, event.prompt_id, event.agent_id ?? null, provider);
|
|
77
|
+
// A notification turn reports to the task it continued (an explicit alias).
|
|
78
|
+
try {
|
|
79
|
+
return resolveReportTask(root, id);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return id;
|
|
83
|
+
}
|
|
77
84
|
}
|
|
78
85
|
export function hookReportTaskId(root, provider, event) {
|
|
79
86
|
try {
|
|
@@ -103,11 +110,19 @@ export function startHookReport(root, provider, event) {
|
|
|
103
110
|
// the episode's graph record is written under the first task's id. The key
|
|
104
111
|
// is a hash; the host session identifier itself is still never retained.
|
|
105
112
|
let links = {};
|
|
106
|
-
|
|
107
|
-
|
|
113
|
+
const sessionKey = hookSessionKey(cwd, provider, event);
|
|
114
|
+
if (sessionKey) {
|
|
108
115
|
links = { session_key: sessionKey };
|
|
109
116
|
try {
|
|
110
117
|
const previous = latestSessionTask(root, sessionKey);
|
|
118
|
+
// A host notification (a background command finished) is not new work:
|
|
119
|
+
// it continues the session's latest task instead of opening an empty row,
|
|
120
|
+
// unless the agent already closed that task for good. The alias makes
|
|
121
|
+
// this prompt's Stop and hook observations report to that task.
|
|
122
|
+
if (previous && previous.task_id !== id && isNotificationPrompt(event.prompt) && previous.closed_by !== "agent") {
|
|
123
|
+
aliasReportTask(root, id, previous.task_id);
|
|
124
|
+
return taskInstruction(previous, cwdLiteral);
|
|
125
|
+
}
|
|
111
126
|
const continued = previous && previous.task_id !== id ? continuationLinks(previous) : null;
|
|
112
127
|
if (continued)
|
|
113
128
|
links = { ...links, ...continued };
|
|
@@ -127,8 +142,41 @@ export function startHookReport(root, provider, event) {
|
|
|
127
142
|
throw error;
|
|
128
143
|
task = existing;
|
|
129
144
|
}
|
|
145
|
+
return taskInstruction(task, cwdLiteral);
|
|
146
|
+
}
|
|
147
|
+
function taskInstruction(task, cwdLiteral) {
|
|
130
148
|
return `Hunch has opened this prompt's report: ${task.task_id}. Reuse this exact ID for this prompt. Call hunch_task(action: "start", task_id: "${task.task_id}", title: ${JSON.stringify(task.title)}, cwd: ${cwdLiteral}) to obtain verification_argv; do not create another report. Pass this task_id and cwd: ${cwdLiteral} to hunch_context and decision/correction/finding captures, and pass the same cwd when finishing with hunch_task before responding. A host Stop notice will show the evidence even if no task-linked memory was observed.`;
|
|
131
149
|
}
|
|
150
|
+
/** The session key a hook event maps to: a hash of (root, provider, session,
|
|
151
|
+
* agent), never the identifier itself. Null without a host session. */
|
|
152
|
+
function hookSessionKey(cwd, provider, event) {
|
|
153
|
+
return event.session_id ? reportHash([cwd, provider, event.session_id, event.agent_id ?? null]) : null;
|
|
154
|
+
}
|
|
155
|
+
/** A prompt the host generated to report a background command's completion,
|
|
156
|
+
* not something the user typed. */
|
|
157
|
+
export function isNotificationPrompt(prompt) {
|
|
158
|
+
if (typeof prompt !== "string")
|
|
159
|
+
return false;
|
|
160
|
+
const firstLine = prompt.split(/\r?\n/).map(l => l.trim()).find(l => l.length > 0) ?? "";
|
|
161
|
+
return /^<task-notification>/i.test(firstLine);
|
|
162
|
+
}
|
|
163
|
+
/** Close, as host closes, the tasks of this session that an earlier prompt left
|
|
164
|
+
* open: the prompt was interrupted before its Stop, or a late observation
|
|
165
|
+
* reopened its task. Called when a new prompt starts (`keepNewest`: the new
|
|
166
|
+
* task, or the task a notification turn continues, stays open) and when the
|
|
167
|
+
* current prompt stops. Returns the ids closed here for the caller to persist. */
|
|
168
|
+
export function settleHookSession(root, provider, event, options = {}) {
|
|
169
|
+
const cwd = nativeHookCwd(root, provider, event);
|
|
170
|
+
const key = cwd ? hookSessionKey(cwd, provider, event) : null;
|
|
171
|
+
if (!key)
|
|
172
|
+
return [];
|
|
173
|
+
try {
|
|
174
|
+
return settleSessionTasks(root, key, options);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
132
180
|
/** Stop ends the turn, so the prompt's task closes here as a HOST close: the
|
|
133
181
|
* ledger says the task completed even when the agent never called finish, and
|
|
134
182
|
* a task with observations becomes a graph record without anyone's cooperation.
|
|
@@ -136,30 +184,34 @@ export function startHookReport(root, provider, event) {
|
|
|
136
184
|
* continuation: the next observation reopens the task and the following Stop
|
|
137
185
|
* closes it again (the record is refreshed from the report). An explicit agent
|
|
138
186
|
* finish with any outcome overrides a host close. Pending verification keeps
|
|
139
|
-
* the task open.
|
|
140
|
-
*
|
|
187
|
+
* the task open. Tasks an earlier prompt of the session left open close here
|
|
188
|
+
* too. Returns the ids of the tasks closed after this call, so the caller can
|
|
189
|
+
* persist their records; empty when nothing is closed. */
|
|
141
190
|
export function closeHookTask(root, provider, event) {
|
|
142
191
|
let id;
|
|
143
192
|
try {
|
|
144
193
|
id = identity(root, provider, event);
|
|
145
194
|
}
|
|
146
195
|
catch {
|
|
147
|
-
return
|
|
196
|
+
return [];
|
|
148
197
|
}
|
|
149
198
|
if (!id || id === "legacy")
|
|
150
|
-
return
|
|
199
|
+
return [];
|
|
200
|
+
const closed = [];
|
|
151
201
|
try {
|
|
152
202
|
const task = readTaskReport(root, id).task;
|
|
153
|
-
if (task.state === "interrupted")
|
|
154
|
-
return null;
|
|
155
203
|
if (task.state === "open")
|
|
156
204
|
finishReportTask(root, id, "completed", { by: "host" });
|
|
157
|
-
|
|
205
|
+
if (task.state !== "interrupted")
|
|
206
|
+
closed.push(id);
|
|
158
207
|
}
|
|
159
208
|
catch {
|
|
160
|
-
// No task for this prompt, or verification still running: leave it as it is.
|
|
161
|
-
return null;
|
|
209
|
+
// No task for this prompt (a notification turn), or verification still running: leave it as it is.
|
|
162
210
|
}
|
|
211
|
+
for (const other of settleHookSession(root, provider, event, { keepId: id }))
|
|
212
|
+
if (!closed.includes(other))
|
|
213
|
+
closed.push(other);
|
|
214
|
+
return closed;
|
|
163
215
|
}
|
|
164
216
|
/** A presentation notice never denies Stop or injects another model turn.
|
|
165
217
|
* A prompt with no observation at all prints nothing: the empty task row stays
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
/** Files Hunch regenerates on every capture (src/integrations/providers.ts,
|
|
2
|
+
* claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
|
|
3
|
+
export declare const HUNCH_MANAGED_FILES: ReadonlySet<string>;
|
|
1
4
|
export declare function gitTouchedFiles(root: string, startedAt: string, finishedAt: string | null, options?: {
|
|
2
5
|
limit?: number;
|
|
3
6
|
timeoutMs?: number;
|
|
4
7
|
now?: number;
|
|
8
|
+
workingTree?: boolean;
|
|
5
9
|
}): string[];
|
package/dist/core/taskTouched.js
CHANGED
|
@@ -8,15 +8,26 @@
|
|
|
8
8
|
* never a failed finish):
|
|
9
9
|
* - commits authored by the configured git user whose commit time falls in
|
|
10
10
|
* the task window (merges excluded);
|
|
11
|
-
* - working-tree changes (modified, added, untracked) whose mtime falls in it
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* - working-tree changes (modified, added, untracked) whose mtime falls in it,
|
|
12
|
+
* unless the caller knows another session shared the checkout (`workingTree:
|
|
13
|
+
* false`): mtimes cannot say whose edit it was, commits can.
|
|
14
|
+
* Hunch's own work never counts as the task's: memory and cache paths, commits
|
|
15
|
+
* Hunch makes (`hunch:` subjects — captures, task records, repairs), and the
|
|
16
|
+
* grounding files a capture rewrites (CLAUDE.md, AGENTS.md, the host rule
|
|
17
|
+
* files) when they merely changed in the working tree; a user commit that
|
|
18
|
+
* edits one of those files still counts, as does a delivery that named it.
|
|
19
|
+
* Deleted paths are skipped: nothing dates the deletion. Commit dates get one
|
|
20
|
+
* second of slack (git keeps seconds); working-tree mtimes get none before
|
|
21
|
+
* the start. */
|
|
16
22
|
import { execFileSync } from "node:child_process";
|
|
17
23
|
import { statSync } from "node:fs";
|
|
18
24
|
import { join } from "node:path";
|
|
19
25
|
const EXCLUDED_SEGMENTS = new Set([".hunch", ".hunch-cache", ".git"]);
|
|
26
|
+
/** Files Hunch regenerates on every capture (src/integrations/providers.ts,
|
|
27
|
+
* claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
|
|
28
|
+
export const HUNCH_MANAGED_FILES = new Set(["CLAUDE.md", "AGENTS.md", ".cursor/rules/hunch.mdc", ".github/copilot-instructions.md", ".windsurf/rules/hunch.md"]);
|
|
29
|
+
/** Commit subjects Hunch writes itself. */
|
|
30
|
+
const HUNCH_COMMIT_SUBJECT = /^hunch:/i;
|
|
20
31
|
function gitDate(ms) {
|
|
21
32
|
// Second resolution, a format every git accepts.
|
|
22
33
|
return `${new Date(ms).toISOString().slice(0, 19).replace("T", " ")} +0000`;
|
|
@@ -38,8 +49,9 @@ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
|
|
|
38
49
|
delete env[key];
|
|
39
50
|
const run = (args) => execFileSync("git", ["-C", root, "-c", "core.quotePath=false", ...args], { env, encoding: "utf8", timeout, maxBuffer: 4_000_000, stdio: ["ignore", "pipe", "ignore"] });
|
|
40
51
|
const out = new Set();
|
|
52
|
+
const normalize = (raw) => raw.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
41
53
|
const keep = (raw) => {
|
|
42
|
-
const path = raw
|
|
54
|
+
const path = normalize(raw);
|
|
43
55
|
if (!path || path.split("/").some((segment) => EXCLUDED_SEGMENTS.has(segment)))
|
|
44
56
|
return;
|
|
45
57
|
out.add(path);
|
|
@@ -47,12 +59,20 @@ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
|
|
|
47
59
|
try {
|
|
48
60
|
const email = run(["config", "--get", "user.email"]).trim();
|
|
49
61
|
if (email) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
// One record per commit: a separator, the subject, then the paths.
|
|
63
|
+
const log = run(["log", "--no-merges", "-n", "50", `--since=${gitDate(from)}`, `--until=${gitDate(to)}`, `--author=${email}`, "--format=%x1e%s", "--name-only"]);
|
|
64
|
+
for (const block of log.split("\x1e")) {
|
|
65
|
+
const [subject = "", ...paths] = block.split("\n");
|
|
66
|
+
if (HUNCH_COMMIT_SUBJECT.test(subject.trim()))
|
|
67
|
+
continue;
|
|
68
|
+
for (const line of paths)
|
|
69
|
+
keep(line);
|
|
70
|
+
}
|
|
53
71
|
}
|
|
54
72
|
}
|
|
55
73
|
catch { /* no commits, no git user, or no git: the working tree may still say something */ }
|
|
74
|
+
if (options.workingTree === false)
|
|
75
|
+
return [...out].sort().slice(0, limit);
|
|
56
76
|
try {
|
|
57
77
|
const entries = run(["status", "--porcelain=v1", "-z", "--untracked-files=all"]).split("\0").filter(Boolean);
|
|
58
78
|
for (let i = 0; i < entries.length; i++) {
|
|
@@ -61,7 +81,7 @@ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
|
|
|
61
81
|
// A rename or copy is followed by its original path as a separate entry.
|
|
62
82
|
if (code[0] === "R" || code[0] === "C")
|
|
63
83
|
i++;
|
|
64
|
-
if (code.includes("D") || !path)
|
|
84
|
+
if (code.includes("D") || !path || HUNCH_MANAGED_FILES.has(normalize(path)))
|
|
65
85
|
continue;
|
|
66
86
|
try {
|
|
67
87
|
const mtime = statSync(join(root, path)).mtimeMs;
|
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;
|