@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.
Files changed (48) hide show
  1. package/dist/cli/index.js +255 -9
  2. package/dist/cli/serve.js +1 -0
  3. package/dist/client/readOrCompute.d.ts +77 -0
  4. package/dist/client/readOrCompute.js +85 -0
  5. package/dist/client/state.d.ts +1 -0
  6. package/dist/client/state.js +1 -0
  7. package/dist/constitution/g2.d.ts +1 -0
  8. package/dist/constitution/service.js +8 -0
  9. package/dist/constitution/sourceMutation.js +23 -18
  10. package/dist/core/config.d.ts +16 -0
  11. package/dist/core/config.js +13 -0
  12. package/dist/core/machine.d.ts +20 -0
  13. package/dist/core/machine.js +101 -0
  14. package/dist/core/taskRecord.js +6 -3
  15. package/dist/core/taskReport.d.ts +25 -2
  16. package/dist/core/taskReport.js +92 -18
  17. package/dist/core/taskReportEvidence.d.ts +4 -1
  18. package/dist/core/taskReportEvidence.js +5 -2
  19. package/dist/core/taskReportHook.d.ts +16 -3
  20. package/dist/core/taskReportHook.js +65 -13
  21. package/dist/core/taskTouched.d.ts +4 -0
  22. package/dist/core/taskTouched.js +30 -10
  23. package/dist/core/types.d.ts +66 -1
  24. package/dist/core/types.js +3 -0
  25. package/dist/core/workspace.d.ts +234 -0
  26. package/dist/core/workspace.js +335 -0
  27. package/dist/extractors/helm.d.ts +17 -28
  28. package/dist/extractors/helm.js +12 -12
  29. package/dist/extractors/indexer.js +171 -7
  30. package/dist/extractors/k8sManifest.d.ts +59 -0
  31. package/dist/extractors/k8sManifest.js +507 -0
  32. package/dist/extractors/workspaces.d.ts +18 -0
  33. package/dist/extractors/workspaces.js +350 -0
  34. package/dist/integrations/claudemd.js +1 -0
  35. package/dist/integrations/hooks.d.ts +2 -0
  36. package/dist/integrations/hooks.js +25 -0
  37. package/dist/integrations/scaffold.js +11 -0
  38. package/dist/integrations/workspaceLedger.d.ts +73 -0
  39. package/dist/integrations/workspaceLedger.js +201 -0
  40. package/dist/mcp/server.js +54 -0
  41. package/dist/mcp/taskReportTools.d.ts +9 -0
  42. package/dist/mcp/taskReportTools.js +13 -5
  43. package/dist/serve/app.d.ts +2 -0
  44. package/dist/serve/app.js +107 -92
  45. package/dist/serve/mcpHttp.d.ts +27 -0
  46. package/dist/serve/mcpHttp.js +95 -0
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -93,16 +93,21 @@ function parsedSymbolFor(graphSymbol, parsed) {
93
93
  const base = symbolId(graphSymbol.file, graphSymbol.name, graphSymbol.kind);
94
94
  return matches.find((_symbol, index) => (index === 0 ? base : `${base}_${index}`) === graphSymbol.id) ?? null;
95
95
  }
96
- function spliceBytes(source, replacements) {
97
- let bytes = Buffer.from(source, "utf8");
96
+ /** `start`/`end` are JS string (UTF-16 code unit) indices. Despite their
97
+ * names, parse.ts's startByte/endByte/atByte carry the same units — native
98
+ * tree-sitter indexes the JS string it was handed, not its UTF-8 encoding —
99
+ * so every scan and splice against them must be string-based, never Buffer-
100
+ * based. */
101
+ function spliceChars(source, replacements) {
102
+ let result = source;
98
103
  for (const replacement of [...replacements].sort((a, b) => b.start - a.start)) {
99
- bytes = Buffer.concat([
100
- bytes.subarray(0, replacement.start),
101
- Buffer.from(replacement.text, "utf8"),
102
- bytes.subarray(replacement.end),
103
- ]);
104
+ result = result.slice(0, replacement.start) + replacement.text + result.slice(replacement.end);
104
105
  }
105
- return bytes.toString("utf8");
106
+ return result;
107
+ }
108
+ /** Where a new top-level statement (an import) can be inserted without splitting a shebang line. */
109
+ function insertionPoint(source) {
110
+ return source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
106
111
  }
107
112
  function mutateSource(policy, base, sourceFile, source) {
108
113
  const assertion = policy.assertion;
@@ -145,10 +150,10 @@ function mutateSource(policy, base, sourceFile, source) {
145
150
  const specifier = relativeSpecifier(sourceFile, targetFile);
146
151
  if (parsed.imports.some((candidate) => candidate === specifier))
147
152
  return { error: "mutation-component-import-already-present" };
148
- const insertion = source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
153
+ const insertion = insertionPoint(source);
149
154
  return {
150
155
  file: sourceFile,
151
- source: spliceBytes(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(specifier)}; // hunch deterministic component mutation\n` }]),
156
+ source: spliceChars(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(specifier)}; // hunch deterministic component mutation\n` }]),
152
157
  };
153
158
  }
154
159
  const subject = symbolForSelector(base, assertion.subject);
@@ -161,7 +166,7 @@ function mutateSource(policy, base, sourceFile, source) {
161
166
  if (!definition)
162
167
  return { error: "mutation-subject-definition-unresolved" };
163
168
  if (assertion.kind === "exists") {
164
- return { file: subject.file, source: spliceBytes(source, [{ start: definition.startByte, end: definition.endByte, text: "" }]) };
169
+ return { file: subject.file, source: spliceChars(source, [{ start: definition.startByte, end: definition.endByte, text: "" }]) };
165
170
  }
166
171
  if (assertion.kind === "not-reaches"
167
172
  && assertion.relation.edges.length === 1
@@ -173,10 +178,10 @@ function mutateSource(policy, base, sourceFile, source) {
173
178
  if (parsed.imports.some((specifier) => externalPackage(specifier) === dependency)) {
174
179
  return { error: "mutation-forbidden-import-already-present" };
175
180
  }
176
- const insertion = source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
181
+ const insertion = insertionPoint(source);
177
182
  return {
178
183
  file: subject.file,
179
- source: spliceBytes(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(dependency)}; // hunch deterministic source mutation\n` }]),
184
+ source: spliceChars(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(dependency)}; // hunch deterministic source mutation\n` }]),
180
185
  };
181
186
  }
182
187
  const object = symbolForSelector(base, assertion.object);
@@ -193,19 +198,19 @@ function mutateSource(policy, base, sourceFile, source) {
193
198
  .map((call) => ({ start: call.atByte, end: call.endByte, text: "hunchMutationRemovedCall" }));
194
199
  if (!replacements.length)
195
200
  return { error: "mutation-required-call-unresolved" };
196
- return { file: subject.file, source: spliceBytes(source, replacements) };
201
+ return { file: subject.file, source: spliceChars(source, replacements) };
197
202
  }
198
203
  if (!assertion.relation.edges.includes("calls"))
199
204
  return { error: "mutation-call-edge-not-supported" };
200
- const bytes = Buffer.from(source, "utf8");
201
- const open = bytes.indexOf("{".charCodeAt(0), definition.startByte);
205
+ // String search, matching definition.startByte/endByte's actual units -- see spliceChars' doc comment.
206
+ const open = source.indexOf("{", definition.startByte);
202
207
  if (open < 0 || open >= definition.endByte)
203
208
  return { error: "mutation-subject-body-unsupported" };
204
209
  const replacements = [{ start: open + 1, end: open + 1, text: `\n ${object.name}(); // hunch deterministic source mutation\n` }];
205
210
  if (object.file !== subject.file) {
206
211
  const specifier = relativeSpecifier(subject.file, object.file);
207
212
  if (!parsed.imports.includes(specifier)) {
208
- const insertion = source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
213
+ const insertion = insertionPoint(source);
209
214
  replacements.push({
210
215
  start: insertion,
211
216
  end: insertion,
@@ -215,7 +220,7 @@ function mutateSource(policy, base, sourceFile, source) {
215
220
  }
216
221
  return {
217
222
  file: subject.file,
218
- source: spliceBytes(source, replacements),
223
+ source: spliceChars(source, replacements),
219
224
  };
220
225
  }
221
226
  function removeWorktree(root, hooks, env, checkout) {
@@ -9,12 +9,28 @@ import type { HunchPaths } from "./paths.js";
9
9
  export type Firmness = "off" | "advisory" | "firm" | "strict";
10
10
  export declare const FIRMNESS_LEVELS: readonly Firmness[];
11
11
  export declare const DEFAULT_FIRMNESS: Firmness;
12
+ /** Workspace-ledger knobs (docs/workspace-ledger.md). `publish` decides what a snapshot
13
+ * carries: `branches` (default — label, branches, verdicts, dirty/locked flags, no paths),
14
+ * `full` (worktree paths too), `off` (no record). `publish_public` lets a repo WITHOUT an
15
+ * overlay commit the record into its tracked .hunch/ — off by default: per-machine facts
16
+ * churning the code repo is rarely wanted. */
17
+ export type WorkspacePublish = "full" | "branches" | "off";
18
+ export declare const WORKSPACE_PUBLISH_MODES: readonly WorkspacePublish[];
19
+ export declare const DEFAULT_WORKSPACE_PUBLISH: WorkspacePublish;
20
+ export interface WorkspacesConfig {
21
+ publish: WorkspacePublish;
22
+ stale_after_days: number;
23
+ publish_public: boolean;
24
+ }
12
25
  export interface HunchConfig {
13
26
  firmness: Firmness;
14
27
  /** MCP tool groups beyond the everyday set: `all`, `core`, or `core,nuryel`
15
28
  * (see src/mcp/toolset.ts). Undefined = decide from the root's contents. */
16
29
  mcp_tools?: string;
30
+ workspaces?: Partial<WorkspacesConfig>;
17
31
  }
32
+ /** The effective workspace config: every field present, unknown values ignored. */
33
+ export declare function workspacesConfig(config: HunchConfig): WorkspacesConfig;
18
34
  export declare function isFirmness(v: unknown): v is Firmness;
19
35
  /** Read `.hunch/config.json`. A missing/unparseable file, or an unknown firmness
20
36
  * value, falls back to defaults — the hook must NEVER crash an edit over config. */
@@ -6,6 +6,18 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
6
  import { dirname } from "node:path";
7
7
  export const FIRMNESS_LEVELS = ["off", "advisory", "firm", "strict"];
8
8
  export const DEFAULT_FIRMNESS = "advisory";
9
+ export const WORKSPACE_PUBLISH_MODES = ["full", "branches", "off"];
10
+ export const DEFAULT_WORKSPACE_PUBLISH = "branches";
11
+ /** The effective workspace config: every field present, unknown values ignored. */
12
+ export function workspacesConfig(config) {
13
+ const raw = config.workspaces ?? {};
14
+ const days = Number(raw.stale_after_days);
15
+ return {
16
+ publish: WORKSPACE_PUBLISH_MODES.includes(raw.publish) ? raw.publish : DEFAULT_WORKSPACE_PUBLISH,
17
+ stale_after_days: Number.isInteger(days) && days >= 1 && days <= 3650 ? days : 7,
18
+ publish_public: raw.publish_public === true,
19
+ };
20
+ }
9
21
  function defaults() {
10
22
  return { firmness: DEFAULT_FIRMNESS };
11
23
  }
@@ -22,6 +34,7 @@ export function readConfig(paths) {
22
34
  return {
23
35
  firmness: isFirmness(raw.firmness) ? raw.firmness : DEFAULT_FIRMNESS,
24
36
  ...(typeof raw.mcp_tools === "string" && raw.mcp_tools.trim() ? { mcp_tools: raw.mcp_tools.trim() } : {}),
37
+ ...(raw.workspaces && typeof raw.workspaces === "object" && !Array.isArray(raw.workspaces) ? { workspaces: raw.workspaces } : {}),
25
38
  };
26
39
  }
27
40
  catch {
@@ -0,0 +1,20 @@
1
+ export interface MachineIdentity {
2
+ id: string;
3
+ label: string;
4
+ created_at: string;
5
+ }
6
+ export interface MachinePathOptions {
7
+ env?: NodeJS.ProcessEnv;
8
+ home?: string;
9
+ platform?: NodeJS.Platform;
10
+ }
11
+ export declare function machineFile(opts?: MachinePathOptions): string;
12
+ export declare function defaultMachineLabel(id: string): string;
13
+ /** The machine's identity, minted on first use. An unreadable or invalid file is
14
+ * replaced (a machine that lost its id simply becomes a new machine; the old record
15
+ * ages out as unverified and `hunch workspaces forget` removes it). */
16
+ export declare function loadOrCreateMachine(opts?: MachinePathOptions): MachineIdentity;
17
+ export declare function setMachineLabel(label: string, opts?: MachinePathOptions): MachineIdentity;
18
+ /** A label that equals the hostname or the OS username publishes personal data into a
19
+ * shared store; `doctor` and `label` warn, they do not refuse — the user chose it. */
20
+ export declare function labelLeaksIdentity(label: string): string | null;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Machine identity for the workspace ledger (docs/workspace-ledger.md): a random id
3
+ * generated ONCE per machine and stored at the user level, so every clone on the
4
+ * machine reports as the same machine. Deliberately NOT derived from the hostname,
5
+ * a MAC address or a hardware serial — it identifies nothing outside Hunch. The
6
+ * label is user-chosen; the default embeds nothing personal.
7
+ *
8
+ * Lives under the platform's per-user config root (XDG_CONFIG_HOME / %APPDATA% /
9
+ * ~/.config) and, like updatecheck.ts, never creates a `.hunch` path segment: that
10
+ * is findRoot()'s repository marker.
11
+ */
12
+ import { lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { homedir, hostname, userInfo } from "node:os";
14
+ import { basename, dirname, join } from "node:path";
15
+ import { randomBytes, randomUUID } from "node:crypto";
16
+ import { MACHINE_ID, MACHINE_LABEL } from "./workspace.js";
17
+ const MAX_MACHINE_FILE_BYTES = 4096;
18
+ function configuredRoot(value, platform) {
19
+ if (!value)
20
+ return null;
21
+ const absolute = platform === "win32" ? /^(?:[A-Za-z]:[\\/]|\\\\)/.test(value) : value.startsWith("/");
22
+ const marker = value.replace(/\\/g, "/").split("/").some((part) => part.toLowerCase().replace(/[ .]+$/g, "") === ".hunch");
23
+ return absolute && !marker ? value : null;
24
+ }
25
+ export function machineFile(opts = {}) {
26
+ const env = opts.env ?? process.env;
27
+ const home = opts.home ?? homedir();
28
+ const platform = opts.platform ?? process.platform;
29
+ const configHome = configuredRoot(env.XDG_CONFIG_HOME, platform)
30
+ || (platform === "win32" && configuredRoot(env.APPDATA, platform))
31
+ || join(home, ".config");
32
+ return join(configHome, "hunch", "machine.json");
33
+ }
34
+ export function defaultMachineLabel(id) {
35
+ return `machine-${id.replace(/^mac_/, "").slice(0, 4)}`;
36
+ }
37
+ function readMachine(file) {
38
+ try {
39
+ const stat = lstatSync(file);
40
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_MACHINE_FILE_BYTES)
41
+ return null;
42
+ const raw = JSON.parse(readFileSync(file, "utf8"));
43
+ if (typeof raw.id !== "string" || !MACHINE_ID.test(raw.id))
44
+ return null;
45
+ const label = typeof raw.label === "string" && MACHINE_LABEL.test(raw.label) ? raw.label : defaultMachineLabel(raw.id);
46
+ const created = typeof raw.created_at === "string" && Number.isFinite(Date.parse(raw.created_at)) ? raw.created_at : new Date(0).toISOString();
47
+ return { id: raw.id, label, created_at: created };
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** Atomic, owner-only write: a half-written id file would mint a second machine. */
54
+ function writeMachine(file, identity) {
55
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
56
+ const temp = join(dirname(file), `.${basename(file)}.${process.pid}.${randomUUID()}.tmp`);
57
+ writeFileSync(temp, JSON.stringify(identity, null, 2) + "\n", { mode: 0o600 });
58
+ renameSync(temp, file);
59
+ }
60
+ /** The machine's identity, minted on first use. An unreadable or invalid file is
61
+ * replaced (a machine that lost its id simply becomes a new machine; the old record
62
+ * ages out as unverified and `hunch workspaces forget` removes it). */
63
+ export function loadOrCreateMachine(opts = {}) {
64
+ const file = machineFile(opts);
65
+ const existing = readMachine(file);
66
+ if (existing)
67
+ return existing;
68
+ const id = `mac_${randomBytes(16).toString("hex")}`;
69
+ const fresh = { id, label: defaultMachineLabel(id), created_at: new Date().toISOString() };
70
+ writeMachine(file, fresh);
71
+ return fresh;
72
+ }
73
+ export function setMachineLabel(label, opts = {}) {
74
+ if (!MACHINE_LABEL.test(label)) {
75
+ throw new Error("machine label must be 1-64 characters of letters, digits, '.', '_' or '-' and start with a letter or digit");
76
+ }
77
+ const next = { ...loadOrCreateMachine(opts), label };
78
+ writeMachine(machineFile(opts), next);
79
+ return next;
80
+ }
81
+ /** A label that equals the hostname or the OS username publishes personal data into a
82
+ * shared store; `doctor` and `label` warn, they do not refuse — the user chose it. */
83
+ export function labelLeaksIdentity(label) {
84
+ const lower = label.toLowerCase();
85
+ let host = "";
86
+ let user = "";
87
+ try {
88
+ host = hostname().toLowerCase();
89
+ }
90
+ catch { /* unavailable */ }
91
+ try {
92
+ user = userInfo().username.toLowerCase();
93
+ }
94
+ catch { /* unavailable */ }
95
+ if (host && (lower === host || lower === host.split(".")[0]))
96
+ return "hostname";
97
+ if (user && lower === user)
98
+ return "OS username";
99
+ return null;
100
+ }
101
+ //# sourceMappingURL=machine.js.map
@@ -14,7 +14,7 @@ import { readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { flushCapture } from "../integrations/sync.js";
16
16
  import { hunchPaths } from "./paths.js";
17
- import { episodeTasks, isEmptyTaskReport, readTaskReport, reportHash } from "./taskReport.js";
17
+ import { episodeTasks, isEmptyTaskReport, readTaskReport, reportHash, sessionsOverlap } from "./taskReport.js";
18
18
  import { reportSourceSnapshot } from "./taskReportEvidence.js";
19
19
  import { ENTITY_KINDS, TaskRecordSchema } from "./types.js";
20
20
  import { refreshRankEval } from "./taskRankingMode.js";
@@ -184,7 +184,10 @@ export function persistTaskRecord(root, store, taskId, options = {}) {
184
184
  const members = headId === own.task.task_id && !own.task.episode ? [own.task] : episodeTasks(root, headId);
185
185
  const reports = (members.length ? members : [own.task]).map((t) => (t.task_id === taskId ? own : readTaskReport(root, t.task_id, snapshot)));
186
186
  const window = { from: reports[0].task.started_at, to: reports.reduce((max, r) => (r.task.finished_at && (!max || r.task.finished_at > max) ? r.task.finished_at : max), null) };
187
- let built = taskRecordFromReports(reports, gitTouchedFiles(root, window.from, window.to));
187
+ // Another session working in the same checkout at the same time leaves the
188
+ // same mtimes: then only commits (attributable by author and time) count.
189
+ const workingTree = !sessionsOverlap(root, reports[0].task.session_key, window.from, window.to, reports.map((r) => r.task.task_id));
190
+ let built = taskRecordFromReports(reports, gitTouchedFiles(root, window.from, window.to, { workingTree }));
188
191
  if (!built)
189
192
  return null;
190
193
  let inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
@@ -194,7 +197,7 @@ export function persistTaskRecord(root, store, taskId, options = {}) {
194
197
  // episode splits here: this prompt keeps its own record instead of naming
195
198
  // private memory in a public one.
196
199
  if (inPublic && !inPrivate && taskRecordHome(store, built) === "private" && built.id !== taskId) {
197
- built = taskRecordFromReports([own], gitTouchedFiles(root, own.task.started_at, own.task.finished_at));
200
+ built = taskRecordFromReports([own], gitTouchedFiles(root, own.task.started_at, own.task.finished_at, { workingTree }));
198
201
  if (!built)
199
202
  return null;
200
203
  inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
@@ -200,6 +200,12 @@ export interface LessonHistory {
200
200
  truncated: boolean;
201
201
  next_before: number | null;
202
202
  }
203
+ /** A prompt identity that reports to another prompt's task: a host notification
204
+ * turn continues the session's latest task instead of opening a row. Explicit,
205
+ * so Stop never selects a task by recency for a prompt it does not know. */
206
+ export declare function aliasReportTask(root: string, aliasId: string, taskId: string): void;
207
+ /** The task an aliased prompt identity reports to, or the identity itself. */
208
+ export declare function resolveReportTask(root: string, id: string): string;
203
209
  /** The lookup is derived, never evidence. Backfill at most 64 events and
204
210
  * 512 KB per read. Parse outside the writer transaction so a history view cannot
205
211
  * hold the writer lock while validating a large legacy ledger. */
@@ -217,14 +223,31 @@ export declare function startReportTask(root: string, title: string, taskId?: st
217
223
  * same work: its task continues the previous one and shares its episode. */
218
224
  export declare const CONTINUATION_WINDOW_MS: number;
219
225
  /** The links a new prompt's task takes from the latest task of its session, or
220
- * null when that task is too old (measured from its close, or its start when it
221
- * was never closed) to be the same work. */
226
+ * null when that task is too old (measured from its close) to be the same work.
227
+ * A task still open is the session's current work however long ago it started:
228
+ * the prompt was interrupted before Stop, or a late observation reopened it. */
222
229
  export declare function continuationLinks(previous: ReportTask | null, nowMs?: number): {
223
230
  continues: string;
224
231
  episode: string;
225
232
  } | null;
226
233
  /** The most recent task of a session in this worktree, or null. */
227
234
  export declare function latestSessionTask(root: string, sessionKey: string): ReportTask | null;
235
+ /** Tasks of a session that an earlier prompt left open are over once the
236
+ * session moves on: the prompt was interrupted before its Stop, a notification
237
+ * turn reused the task, or a late observation reopened it. Close them as host
238
+ * closes, except `keepId` (the prompt now running), the session's newest task
239
+ * when `keepNewest` (a notification turn continues it), and any task whose
240
+ * verification may still deliver a result. Returns the ids closed here so the
241
+ * caller can persist their records. */
242
+ export declare function settleSessionTasks(root: string, sessionKey: string, options?: {
243
+ keepId?: string | null;
244
+ keepNewest?: boolean;
245
+ }): string[];
246
+ /** Whether a task of ANOTHER session (or of no session) was open in this
247
+ * worktree during the window: working-tree edits made then cannot be told apart
248
+ * by mtime, so the caller attributes only commits. `ownIds` are the episode's
249
+ * own tasks; any other task without a session key counts as foreign. */
250
+ export declare function sessionsOverlap(root: string, sessionKey: string | undefined, from: string, to: string | null, ownIds?: readonly string[]): boolean;
228
251
  /** Every task of an episode, oldest first: the head and the prompts that continued it. */
229
252
  export declare function episodeTasks(root: string, headId: string): ReportTask[];
230
253
  /** The record revisions among `records` that this task has not received before.
@@ -115,10 +115,25 @@ function taskDb(root, run) {
115
115
  );
116
116
  CREATE INDEX IF NOT EXISTS report_record_lookup ON report_record_links(kind, record_id, content_hash);
117
117
  CREATE TABLE IF NOT EXISTS report_history_progress (id INTEGER PRIMARY KEY CHECK(id = 1), through_rowid INTEGER NOT NULL);
118
- INSERT OR IGNORE INTO report_history_progress VALUES (1, 0);`);
118
+ INSERT OR IGNORE INTO report_history_progress VALUES (1, 0);
119
+ CREATE TABLE IF NOT EXISTS report_task_aliases (alias_id TEXT PRIMARY KEY, task_id TEXT NOT NULL);`);
119
120
  return run(db);
120
121
  });
121
122
  }
123
+ /** A prompt identity that reports to another prompt's task: a host notification
124
+ * turn continues the session's latest task instead of opening a row. Explicit,
125
+ * so Stop never selects a task by recency for a prompt it does not know. */
126
+ export function aliasReportTask(root, aliasId, taskId) {
127
+ TaskIdSchema.parse(aliasId);
128
+ taskDb(root, db => transaction(db, () => {
129
+ readTask(db, root, taskId);
130
+ db.prepare("INSERT OR REPLACE INTO report_task_aliases VALUES (?, ?)").run(aliasId, taskId);
131
+ }));
132
+ }
133
+ /** The task an aliased prompt identity reports to, or the identity itself. */
134
+ export function resolveReportTask(root, id) {
135
+ return taskDb(root, db => db.prepare("SELECT task_id FROM report_task_aliases WHERE alias_id = ?").get(id)?.task_id ?? id);
136
+ }
122
137
  function deliveryRecords(kind, body) {
123
138
  if (kind === "save")
124
139
  return [ReportSaveSchema.parse(body).record];
@@ -261,21 +276,69 @@ export function startReportTask(root, title, taskId, links = {}) {
261
276
  * same work: its task continues the previous one and shares its episode. */
262
277
  export const CONTINUATION_WINDOW_MS = 30 * 60_000;
263
278
  /** The links a new prompt's task takes from the latest task of its session, or
264
- * null when that task is too old (measured from its close, or its start when it
265
- * was never closed) to be the same work. */
279
+ * null when that task is too old (measured from its close) to be the same work.
280
+ * A task still open is the session's current work however long ago it started:
281
+ * the prompt was interrupted before Stop, or a late observation reopened it. */
266
282
  export function continuationLinks(previous, nowMs = Date.now()) {
267
283
  if (!previous)
268
284
  return null;
285
+ const links = { continues: previous.task_id, episode: previous.episode ?? previous.task_id };
286
+ if (previous.state === "open")
287
+ return links;
269
288
  const reference = Date.parse(previous.finished_at ?? previous.started_at);
270
289
  if (!Number.isFinite(reference) || nowMs - reference > CONTINUATION_WINDOW_MS)
271
290
  return null;
272
- return { continues: previous.task_id, episode: previous.episode ?? previous.task_id };
291
+ return links;
292
+ }
293
+ function newestSessionTask(db, root, sessionKey) {
294
+ const row = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.session_key') = ? ORDER BY rowid DESC LIMIT 1").get(...scopePair(root), sessionKey);
295
+ return row ? TaskSchema.parse(JSON.parse(row.body)) : null;
273
296
  }
274
297
  /** The most recent task of a session in this worktree, or null. */
275
298
  export function latestSessionTask(root, sessionKey) {
299
+ return taskDb(root, db => newestSessionTask(db, root, sessionKey));
300
+ }
301
+ /** Check-starts still inside their own timeout plus the grace window, minus the
302
+ * results that arrived: while positive, a runner may still deliver a result. */
303
+ function pendingChecks(db, taskId) {
304
+ const { pending } = db.prepare(`SELECT SUM(CASE WHEN kind = 'check-start' AND (julianday('now') - julianday(at)) * 86400000 < COALESCE(json_extract(body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending FROM report_events WHERE task_id = ?`).get(taskId);
305
+ return pending ?? 0;
306
+ }
307
+ /** Tasks of a session that an earlier prompt left open are over once the
308
+ * session moves on: the prompt was interrupted before its Stop, a notification
309
+ * turn reused the task, or a late observation reopened it. Close them as host
310
+ * closes, except `keepId` (the prompt now running), the session's newest task
311
+ * when `keepNewest` (a notification turn continues it), and any task whose
312
+ * verification may still deliver a result. Returns the ids closed here so the
313
+ * caller can persist their records. */
314
+ export function settleSessionTasks(root, sessionKey, options = {}) {
315
+ return taskDb(root, db => transaction(db, () => {
316
+ const rows = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.session_key') = ? AND json_extract(body, '$.state') = 'open' ORDER BY rowid").all(...scopePair(root), sessionKey);
317
+ const newest = options.keepNewest ? newestSessionTask(db, root, sessionKey)?.task_id : undefined;
318
+ const closed = [];
319
+ for (const row of rows) {
320
+ const task = TaskSchema.parse(JSON.parse(row.body));
321
+ if (task.task_id === options.keepId || task.task_id === newest || pendingChecks(db, task.task_id) > 0)
322
+ continue;
323
+ db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(TaskSchema.parse({ ...task, state: "completed", finished_at: new Date().toISOString(), closed_by: "host" })), task.task_id);
324
+ closed.push(task.task_id);
325
+ }
326
+ return closed;
327
+ }));
328
+ }
329
+ /** Whether a task of ANOTHER session (or of no session) was open in this
330
+ * worktree during the window: working-tree edits made then cannot be told apart
331
+ * by mtime, so the caller attributes only commits. `ownIds` are the episode's
332
+ * own tasks; any other task without a session key counts as foreign. */
333
+ export function sessionsOverlap(root, sessionKey, from, to, ownIds = []) {
276
334
  return taskDb(root, db => {
277
- const row = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.session_key') = ? ORDER BY rowid DESC LIMIT 1").get(...scopePair(root), sessionKey);
278
- return row ? TaskSchema.parse(JSON.parse(row.body)) : null;
335
+ const rows = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.started_at') <= ? AND (json_extract(body, '$.finished_at') IS NULL OR json_extract(body, '$.finished_at') >= ?)").all(...scopePair(root), to ?? new Date().toISOString(), from);
336
+ return rows.some(r => {
337
+ const task = TaskSchema.parse(JSON.parse(r.body));
338
+ if (ownIds.includes(task.task_id))
339
+ return false;
340
+ return !sessionKey || !task.session_key || task.session_key !== sessionKey;
341
+ });
279
342
  });
280
343
  }
281
344
  /** Every task of an episode, oldest first: the head and the prompts that continued it. */
@@ -298,36 +361,47 @@ function appendEvent(root, taskId, kind, body, eventId) {
298
361
  throw new Error("report event identity conflicts with existing evidence");
299
362
  return id;
300
363
  }
364
+ // The task the observation lands on: the named one, unless the host closed
365
+ // it and the session has since moved on.
366
+ let target = task;
301
367
  if (task.state !== "open" && !(kind === "check" && task.state === "interrupted")) {
302
368
  if (task.closed_by !== "host")
303
369
  throw new Error("task is already closed; start a new task for new work");
304
- // The host closed this task at Stop, but the turn went on (another hook's
305
- // block, a resumed prompt). Reopen it for the new observation; the next
306
- // Stop closes it again and the graph record is refreshed from the report.
307
- db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(TaskSchema.parse({ ...task, state: "open", finished_at: null, closed_by: undefined })), taskId);
370
+ // The host closed this task at Stop. When a later prompt of the session
371
+ // exists, this is that prompt's work named by an old id (the grounding
372
+ // says to reuse ids): it lands on the session's newest task, which the
373
+ // next Stop closes, instead of reopening one no Stop would close again.
374
+ // Verification stays on its own task (a result must match its start), and
375
+ // a task the agent closed is final. Otherwise the turn went on (another
376
+ // hook's block, a resumed prompt): reopen; the next Stop closes it again.
377
+ const newest = kind === "check" || kind === "check-start" || !task.session_key ? null : newestSessionTask(db, root, task.session_key);
378
+ if (newest && newest.task_id !== task.task_id && (newest.state === "open" || newest.closed_by === "host"))
379
+ target = newest;
380
+ if (target.state !== "open") {
381
+ target = TaskSchema.parse({ ...target, state: "open", finished_at: null, closed_by: undefined });
382
+ db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(target), target.task_id);
383
+ }
308
384
  }
385
+ const tid = target.task_id;
309
386
  if (kind === "check") {
310
387
  const check = ReportCheckSchema.parse(body);
311
- if (!check.check_id || !db.prepare("SELECT event_id FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'check-start'").get(check.check_id, taskId))
388
+ if (!check.check_id || !db.prepare("SELECT event_id FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'check-start'").get(check.check_id, tid))
312
389
  throw new Error("verification result has no matching start in this task");
313
390
  }
314
- const { total, bytes, pending } = db.prepare(`SELECT COUNT(*) AS total,
315
- COALESCE(SUM(length(CAST(body AS BLOB))), 0) AS bytes,
316
- SUM(CASE WHEN kind = 'check-start' AND (julianday('now') - julianday(at)) * 86400000 < COALESCE(json_extract(body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending
317
- FROM report_events WHERE task_id = ?`).get(taskId);
318
- const reserved = Math.max(0, (pending ?? 0) + (kind === "check-start" ? 1 : kind === "check" ? -1 : 0));
391
+ const { total, bytes } = db.prepare("SELECT COUNT(*) AS total, COALESCE(SUM(length(CAST(body AS BLOB))), 0) AS bytes FROM report_events WHERE task_id = ?").get(tid);
392
+ const reserved = Math.max(0, pendingChecks(db, tid) + (kind === "check-start" ? 1 : kind === "check" ? -1 : 0));
319
393
  if (total + 1 + reserved > MAX_EVENTS || bytes + Buffer.byteLength(encoded) + reserved * MAX_EVENT_BYTES > MAX_TASK_BYTES)
320
394
  throw new Error("task observation limit reached; start a new task");
321
395
  if (kind === "claim") {
322
396
  const claim = ReportClaimSchema.parse(body);
323
- const row = db.prepare("SELECT body FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'delivery'").get(claim.occurrence_id, taskId);
397
+ const row = db.prepare("SELECT body FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'delivery'").get(claim.occurrence_id, tid);
324
398
  if (!row)
325
399
  throw new Error("claim does not refer to a delivery in this task");
326
400
  const delivery = JSON.parse(row.body);
327
401
  if (!delivery.records.some(r => r.record_id === claim.record_id && r.content_hash === claim.content_hash))
328
402
  throw new Error("claim record revision was not delivered in this task");
329
403
  }
330
- const inserted = db.prepare("INSERT INTO report_events VALUES (?, ?, ?, ?, ?, ?)").run(id, taskId, kind, new Date().toISOString(), encoded, contentHash);
404
+ const inserted = db.prepare("INSERT INTO report_events VALUES (?, ?, ?, ?, ?, ?)").run(id, tid, kind, new Date().toISOString(), encoded, contentHash);
331
405
  indexDeliveryRecords(db, id, kind, body);
332
406
  // Advance only over contiguous observed inserts; an older writer may have
333
407
  // left unindexed events. Historical gaps are filled by bounded reads above.
@@ -17,7 +17,10 @@ export declare function reportSourceSnapshot(root: string): ReportSnapshot;
17
17
  * predicate's subject lives in a changed file. Everything else stays
18
18
  * `not-exercised` or `unavailable` — never "satisfied" by file overlap. */
19
19
  export declare function runReportConformance(root: string, store: HunchStore, taskId: string): ReportConformance[];
20
- export declare const DEFAULT_CHECK_TIMEOUT_MS = 120000;
20
+ /** A full suite is the usual check; two minutes turned passing suites into
21
+ * recorded timeouts (#268). The bound is a safety net for an abandoned runner,
22
+ * not a verdict. */
23
+ export declare const DEFAULT_CHECK_TIMEOUT_MS: number;
21
24
  export declare const MAX_CHECK_TIMEOUT_MS: number;
22
25
  /** A deliberately explicit command wrapper. The caller chooses the command;
23
26
  * reports never execute commands automatically to validate submitted claims. */
@@ -192,11 +192,14 @@ export function runReportConformance(root, store, taskId) {
192
192
  return value;
193
193
  });
194
194
  }
195
- export const DEFAULT_CHECK_TIMEOUT_MS = 120_000;
195
+ /** A full suite is the usual check; two minutes turned passing suites into
196
+ * recorded timeouts (#268). The bound is a safety net for an abandoned runner,
197
+ * not a verdict. */
198
+ export const DEFAULT_CHECK_TIMEOUT_MS = 15 * 60_000;
196
199
  export const MAX_CHECK_TIMEOUT_MS = 6 * 60 * 60_000;
197
200
  /** A deliberately explicit command wrapper. The caller chooses the command;
198
201
  * reports never execute commands automatically to validate submitted claims. */
199
- export async function runReportCheck(root, taskId, command, label, timeoutMs = 120_000, options = {}) {
202
+ export async function runReportCheck(root, taskId, command, label, timeoutMs = DEFAULT_CHECK_TIMEOUT_MS, options = {}) {
200
203
  // A full suite can legitimately run for half an hour (fnd_70dd5c4034); the
201
204
  // bound exists so an abandoned runner cannot hold a task open indefinitely.
202
205
  if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_CHECK_TIMEOUT_MS)
@@ -21,6 +21,18 @@ export declare function hookReportTaskId(root: string, provider: HookProvider, e
21
21
  * No raw prompt, host session identifier, or transcript is retained; a repository
22
22
  * that opts in (`taskTitles: "prompt"`) keeps only a bounded first-line title. */
23
23
  export declare function startHookReport(root: string, provider: HookProvider, event: HunchHookInput): string | null;
24
+ /** A prompt the host generated to report a background command's completion,
25
+ * not something the user typed. */
26
+ export declare function isNotificationPrompt(prompt: string | undefined): boolean;
27
+ /** Close, as host closes, the tasks of this session that an earlier prompt left
28
+ * open: the prompt was interrupted before its Stop, or a late observation
29
+ * reopened its task. Called when a new prompt starts (`keepNewest`: the new
30
+ * task, or the task a notification turn continues, stays open) and when the
31
+ * current prompt stops. Returns the ids closed here for the caller to persist. */
32
+ export declare function settleHookSession(root: string, provider: HookProvider, event: HunchHookInput, options?: {
33
+ keepId?: string | null;
34
+ keepNewest?: boolean;
35
+ }): string[];
24
36
  /** Stop ends the turn, so the prompt's task closes here as a HOST close: the
25
37
  * ledger says the task completed even when the agent never called finish, and
26
38
  * a task with observations becomes a graph record without anyone's cooperation.
@@ -28,9 +40,10 @@ export declare function startHookReport(root: string, provider: HookProvider, ev
28
40
  * continuation: the next observation reopens the task and the following Stop
29
41
  * closes it again (the record is refreshed from the report). An explicit agent
30
42
  * finish with any outcome overrides a host close. Pending verification keeps
31
- * the task open. Returns the task id when the task is closed after this call,
32
- * so the caller can persist its record; null when nothing is closed. */
33
- export declare function closeHookTask(root: string, provider: HookProvider, event: HunchHookInput): string | null;
43
+ * the task open. Tasks an earlier prompt of the session left open close here
44
+ * too. Returns the ids of the tasks closed after this call, so the caller can
45
+ * persist their records; empty when nothing is closed. */
46
+ export declare function closeHookTask(root: string, provider: HookProvider, event: HunchHookInput): string[];
34
47
  /** A presentation notice never denies Stop or injects another model turn.
35
48
  * A prompt with no observation at all prints nothing: the empty task row stays
36
49
  * in the ledger (hunch task list, the VS Code Contribution view) so "never