@davesheffer/hunch 1.37.1 → 1.38.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 CHANGED
@@ -88,7 +88,8 @@ import { recordServed, servedSummary } from "../core/served.js";
88
88
  import { recordTaskDelivery, reportActivity, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
89
89
  import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
90
90
  import { renderRecalledLine } from "../core/taskReportRender.js";
91
- import { hookReportTaskId, nativeHookCwd, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
91
+ import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
92
+ import { persistTaskRecord } from "../core/taskRecord.js";
92
93
  import { recordHookObservation } from "../core/hookObservations.js";
93
94
  import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
94
95
  import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, unverifiedNag, } from "../core/pipeline.js";
@@ -4548,6 +4549,17 @@ program
4548
4549
  return;
4549
4550
  }
4550
4551
  }
4552
+ // The turn is over: close the prompt's task and keep its record, whether
4553
+ // or not the agent called finish. Fail-open: the card below still renders.
4554
+ try {
4555
+ const closed = closeHookTask(root, provider, evt);
4556
+ if (closed.length) {
4557
+ store ??= new HunchStore(paths);
4558
+ for (const id of closed)
4559
+ persistTaskRecord(root, store, id);
4560
+ }
4561
+ }
4562
+ catch { /* the ledger and the card remain authoritative; the next finish retries */ }
4551
4563
  const report = stopHookReport(root, provider, evt);
4552
4564
  if (report)
4553
4565
  console.log(JSON.stringify(report));
@@ -4575,6 +4587,17 @@ program
4575
4587
  }
4576
4588
  }
4577
4589
  catch { /* passive reporting remains fail-open */ }
4590
+ // A task an earlier prompt of this session left open (interrupted before
4591
+ // its Stop) is over now: close it and keep its record.
4592
+ try {
4593
+ const settled = settleHookSession(root, provider, evt, { keepId: hookReportTaskId(root, provider, evt), keepNewest: true });
4594
+ if (settled.length) {
4595
+ store ??= new HunchStore(paths);
4596
+ for (const id of settled)
4597
+ persistTaskRecord(root, store, id);
4598
+ }
4599
+ }
4600
+ catch { /* the next Stop or prompt retries */ }
4578
4601
  // Pipeline turn bookkeeping (fresh block budget) + the one nag that must
4579
4602
  // repeat: edits from an earlier turn still unverified.
4580
4603
  if (evt.session_id && pipelineEnabled()) {
@@ -90,7 +90,7 @@ export function registerTaskReportCommands(program, openStore) {
90
90
  return;
91
91
  }
92
92
  for (const s of summaries)
93
- console.log(`${s.task.started_at.slice(0, 16).replace("T", " ")} ${s.task.task_id} ${s.task.state.padEnd(11)} ${renderTaskStatusLine(s) || "nothing observed"}${s.durable ? ` [graph: ${s.durable.home}]` : ""}`);
93
+ console.log(`${s.task.started_at.slice(0, 16).replace("T", " ")} ${s.task.task_id} ${s.task.state.padEnd(11)} ${renderTaskStatusLine(s) || "nothing observed"}${s.durable ? ` [graph: ${s.durable.home}${s.task.episode ? ` as ${s.task.episode}` : ""}]` : ""}${s.task.continues && !s.durable ? ` (continues ${s.task.continues})` : ""}`);
94
94
  });
95
95
  task.command("stats").description("Adherence over a window: how many prompts Hunch reached (delivery), checked, saved, or guarded — from the ledger, never from agent claims")
96
96
  .option("--days <days>", "window in days", "7")
@@ -12,8 +12,17 @@ export declare function taskRecordFlushMode(root: string): "each" | "batch";
12
12
  /** A delivery target that names code (a path or dotted symbol), not a task
13
13
  * phrase like "fix the login redirect". Phrases never become file anchors. */
14
14
  export declare function targetLooksLikePath(target: string): boolean;
15
- /** The durable summary of a finished report, or null when there is nothing to keep. */
16
- export declare function taskRecordFromReport(report: TaskReport): TaskRecord | null;
15
+ /** The durable summary of a finished report, or null when there is nothing to keep.
16
+ * `touched` adds file anchors the report itself cannot know (git-side work while
17
+ * the task was open); report-derived files come first under the cap. */
18
+ export declare function taskRecordFromReport(report: TaskReport, touched?: readonly string[]): TaskRecord | null;
19
+ /** One record for an EPISODE: a prompt's report and the reports of the prompts
20
+ * that continued it in the same session (oldest first). Observations are the
21
+ * union in order, the id and start are the head's, the title is the first
22
+ * non-generic one, state and finish come from the latest closed member, and
23
+ * the report hash covers every member so a refresh is idempotent. Members
24
+ * still open contribute nothing yet. Null when nothing was observed at all. */
25
+ export declare function taskRecordFromReports(reports: readonly TaskReport[], touched?: readonly string[]): TaskRecord | null;
17
26
  /** Where the record belongs. Anything that touched the private overlay — a
18
27
  * private save, or a delivered lesson that lives only there — must not be named
19
28
  * in a public record; the store's own routing (unified/shared mode) wins first. */
@@ -14,10 +14,11 @@ 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 { 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";
21
+ import { gitTouchedFiles } from "./taskTouched.js";
21
22
  function localConfig(root) {
22
23
  try {
23
24
  return JSON.parse(readFileSync(join(root, ".hunch", "local.json"), "utf8"));
@@ -47,50 +48,76 @@ export function targetLooksLikePath(target) {
47
48
  return false;
48
49
  return /[./\\]/.test(t) && !/^\.+$/.test(t);
49
50
  }
50
- /** The durable summary of a finished report, or null when there is nothing to keep. */
51
- export function taskRecordFromReport(report) {
52
- const { task } = report;
53
- if (task.state === "open" || !task.finished_at)
54
- return null;
55
- if (isEmptyTaskReport(report))
51
+ /** The durable summary of a finished report, or null when there is nothing to keep.
52
+ * `touched` adds file anchors the report itself cannot know (git-side work while
53
+ * the task was open); report-derived files come first under the cap. */
54
+ export function taskRecordFromReport(report, touched = []) {
55
+ return taskRecordFromReports([report], touched);
56
+ }
57
+ const GENERIC_TASK_TITLES = new Set(["Assistant task", "Claude task"]);
58
+ const COVERAGE_RANK = { "no-delivery-observed": 0, "no-relevant-memory": 1, delivered: 2 };
59
+ /** One record for an EPISODE: a prompt's report and the reports of the prompts
60
+ * that continued it in the same session (oldest first). Observations are the
61
+ * union in order, the id and start are the head's, the title is the first
62
+ * non-generic one, state and finish come from the latest closed member, and
63
+ * the report hash covers every member so a refresh is idempotent. Members
64
+ * still open contribute nothing yet. Null when nothing was observed at all. */
65
+ export function taskRecordFromReports(reports, touched = []) {
66
+ const closed = reports.filter((r) => r.task.state !== "open" && r.task.finished_at);
67
+ if (!closed.length || closed.every((r) => isEmptyTaskReport(r)))
56
68
  return null;
69
+ const head = reports[0].task;
70
+ const last = closed.reduce((a, b) => (b.task.finished_at >= a.task.finished_at ? b : a));
71
+ const title = reports.map((r) => r.task.title).find((t) => !GENERIC_TASK_TITLES.has(t)) ?? head.title;
57
72
  const lessons = new Map();
58
- for (const delivery of report.deliveries) {
59
- for (const r of delivery.records) {
60
- lessons.set(`${r.kind}:${r.record_id}:${r.content_hash}`, { kind: r.kind, record_id: r.record_id, content_hash: r.content_hash, title: r.title.slice(0, 200) });
61
- }
62
- }
63
73
  const files = new Set();
64
- for (const d of report.deliveries)
65
- if (d.target && targetLooksLikePath(d.target))
66
- files.add(d.target.trim().replace(/\\/g, "/"));
67
- for (const c of report.conformance)
68
- for (const f of c.files)
69
- files.add(f);
70
- for (const r of report.refusals)
71
- files.add(r.target);
72
74
  const latestRule = new Map();
73
- for (const c of report.conformance) {
74
- latestRule.set(`${c.kind}:${c.record_id}:${c.content_hash}`, { kind: c.kind, record_id: c.record_id, content_hash: c.content_hash, outcome: c.outcome });
75
+ const applied = [], saved = [], checks = [];
76
+ let refusals = 0, coverage = "no-delivery-observed", sourceSnapshot = null;
77
+ for (const report of closed) {
78
+ for (const delivery of report.deliveries) {
79
+ if (delivery.target && targetLooksLikePath(delivery.target))
80
+ files.add(delivery.target.trim().replace(/\\/g, "/"));
81
+ for (const r of delivery.records)
82
+ lessons.set(`${r.kind}:${r.record_id}:${r.content_hash}`, { kind: r.kind, record_id: r.record_id, content_hash: r.content_hash, title: r.title.slice(0, 200) });
83
+ }
84
+ for (const c of report.conformance) {
85
+ for (const f of c.files)
86
+ files.add(f);
87
+ latestRule.set(`${c.kind}:${c.record_id}:${c.content_hash}`, { kind: c.kind, record_id: c.record_id, content_hash: c.content_hash, outcome: c.outcome });
88
+ }
89
+ for (const r of report.refusals)
90
+ files.add(r.target);
91
+ applied.push(...report.claims.map((c) => ({ record_id: c.record_id, content_hash: c.content_hash, action: c.action.slice(0, 300), supported_by: c.supported_by })));
92
+ saved.push(...report.saves.map((s) => ({ kind: s.record.kind, record_id: s.record.record_id, content_hash: s.record.content_hash, home: s.home, operation: s.operation, durability: s.durability })));
93
+ checks.push(...report.checks.map((c) => ({ label: c.label, state: (c.cancelled ? "cancelled" : c.timed_out ? "timed out" : c.exit_code === 0 ? "passed" : "failed"), exit_code: c.exit_code })));
94
+ refusals += report.refusals.length;
95
+ if (COVERAGE_RANK[report.coverage] > COVERAGE_RANK[coverage])
96
+ coverage = report.coverage;
97
+ const lastCheck = report.checks.at(-1);
98
+ if (lastCheck)
99
+ sourceSnapshot = lastCheck.after_snapshot ?? null;
75
100
  }
76
- const lastCheck = report.checks.at(-1);
101
+ const fromReport = [...files].sort();
102
+ const extra = [...new Set(touched.map((f) => f.trim().replace(/\\/g, "/")).filter((f) => f && !files.has(f)))].sort();
77
103
  return TaskRecordSchema.parse({
78
- id: task.task_id,
79
- title: task.title,
80
- state: task.state,
81
- started_at: task.started_at,
82
- finished_at: task.finished_at,
83
- coverage: report.coverage,
104
+ id: head.task_id,
105
+ title,
106
+ state: last.task.state,
107
+ started_at: head.started_at,
108
+ finished_at: last.task.finished_at,
109
+ coverage,
84
110
  lessons: [...lessons.values()],
85
- applied: report.claims.map((c) => ({ record_id: c.record_id, content_hash: c.content_hash, action: c.action.slice(0, 300), supported_by: c.supported_by })),
86
- saved: report.saves.map((s) => ({ kind: s.record.kind, record_id: s.record.record_id, content_hash: s.record.content_hash, home: s.home, operation: s.operation, durability: s.durability })),
87
- checks: report.checks.map((c) => ({ label: c.label, state: c.cancelled ? "cancelled" : c.timed_out ? "timed out" : c.exit_code === 0 ? "passed" : "failed", exit_code: c.exit_code })),
111
+ applied,
112
+ saved,
113
+ checks: checks.slice(-64),
88
114
  conformance: [...latestRule.values()],
89
- refusals: report.refusals.length,
90
- files: [...files].sort().slice(0, 64),
91
- source_snapshot: lastCheck?.after_snapshot ?? null,
92
- report_hash: report.content_hash,
93
- provenance: { source: "task_report", confidence: 1, evidence: [`hunch report ${task.task_id}`], last_verified: task.finished_at },
115
+ refusals,
116
+ files: [...fromReport, ...extra].slice(0, 64),
117
+ source_snapshot: sourceSnapshot,
118
+ // One member: its own hash, so records written before episodes existed do not all refresh.
119
+ report_hash: closed.length === 1 ? closed[0].content_hash : reportHash(closed.map((r) => r.content_hash)),
120
+ provenance: { source: "task_report", confidence: 1, evidence: closed.slice(0, 20).map((r) => `hunch report ${r.task.task_id}`), last_verified: last.task.finished_at },
94
121
  });
95
122
  }
96
123
  /** Where the record belongs. Anything that touched the private overlay — a
@@ -145,13 +172,38 @@ export function computeSupersedes(record, others, limit = 20) {
145
172
  export function persistTaskRecord(root, store, taskId, options = {}) {
146
173
  if (!taskRecordsEnabled(root))
147
174
  return null;
148
- const report = readTaskReport(root, taskId, reportSourceSnapshot(root).hash);
149
- const built = taskRecordFromReport(report);
175
+ const snapshot = reportSourceSnapshot(root).hash;
176
+ const own = readTaskReport(root, taskId, snapshot);
177
+ if (own.task.state === "open")
178
+ return null;
179
+ // The record covers the whole episode: this prompt and the prompts of the
180
+ // same session it continued. Work done outside an instrumented editor (shell
181
+ // edits, rebases, release commits) still anchors it: git says what changed
182
+ // while the episode was open.
183
+ const headId = own.task.episode ?? own.task.task_id;
184
+ const members = headId === own.task.task_id && !own.task.episode ? [own.task] : episodeTasks(root, headId);
185
+ const reports = (members.length ? members : [own.task]).map((t) => (t.task_id === taskId ? own : readTaskReport(root, t.task_id, snapshot)));
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
+ // 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 }));
150
191
  if (!built)
151
192
  return null;
193
+ let inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
194
+ let inPublic = store.json.get("tasks", built.id);
195
+ // A record never changes home. If the episode's record already lives in the
196
+ // public store and this prompt brought private-only memory into it, the
197
+ // episode splits here: this prompt keeps its own record instead of naming
198
+ // private memory in a public one.
199
+ if (inPublic && !inPrivate && taskRecordHome(store, built) === "private" && built.id !== taskId) {
200
+ built = taskRecordFromReports([own], gitTouchedFiles(root, own.task.started_at, own.task.finished_at, { workingTree }));
201
+ if (!built)
202
+ return null;
203
+ inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
204
+ inPublic = store.json.get("tasks", built.id);
205
+ }
152
206
  const record = { ...built, supersedes: computeSupersedes(built, store.recs("tasks")) };
153
- const inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", record.id) : undefined;
154
- const inPublic = store.json.get("tasks", record.id);
155
207
  const home = inPrivate ? "private" : inPublic ? "public" : taskRecordHome(store, record);
156
208
  const existing = home === "private" ? inPrivate : inPublic;
157
209
  if (existing && existing.report_hash === record.report_hash)
@@ -202,7 +254,10 @@ export function mergeDurableTaskSummaries(store, summaries, limit = 30) {
202
254
  const seen = new Set();
203
255
  const merged = summaries.map((s) => {
204
256
  seen.add(s.task.task_id);
205
- const home = homes.get(s.task.task_id);
257
+ // A continued prompt's record lives under its episode head.
258
+ const home = homes.get(s.task.task_id) ?? homes.get(s.task.episode ?? "");
259
+ if (s.task.episode)
260
+ seen.add(s.task.episode);
206
261
  return { ...s, durable: home ? { home } : null };
207
262
  });
208
263
  for (const r of records.values()) {
@@ -123,6 +123,13 @@ declare const TaskSchema: z.ZodObject<{
123
123
  completed: "completed";
124
124
  interrupted: "interrupted";
125
125
  }>;
126
+ closed_by: z.ZodOptional<z.ZodEnum<{
127
+ host: "host";
128
+ agent: "agent";
129
+ }>>;
130
+ session_key: z.ZodOptional<z.ZodString>;
131
+ continues: z.ZodOptional<z.ZodString>;
132
+ episode: z.ZodOptional<z.ZodString>;
126
133
  }, z.core.$strict>;
127
134
  export type ReportTask = z.infer<typeof TaskSchema>;
128
135
  export interface TaskDelivery {
@@ -193,6 +200,12 @@ export interface LessonHistory {
193
200
  truncated: boolean;
194
201
  next_before: number | null;
195
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;
196
209
  /** The lookup is derived, never evidence. Backfill at most 64 events and
197
210
  * 512 KB per read. Parse outside the writer transaction so a history view cannot
198
211
  * hold the writer lock while validating a large legacy ledger. */
@@ -200,7 +213,43 @@ export declare function readLessonHistory(root: string, reference: LessonReferen
200
213
  limit?: number;
201
214
  before?: number;
202
215
  }): LessonHistory;
203
- export declare function startReportTask(root: string, title: string, taskId?: string): ReportTask;
216
+ export interface TaskLinks {
217
+ session_key?: string;
218
+ continues?: string;
219
+ episode?: string;
220
+ }
221
+ export declare function startReportTask(root: string, title: string, taskId?: string, links?: TaskLinks): ReportTask;
222
+ /** A prompt that follows another in the same session within this window is the
223
+ * same work: its task continues the previous one and shares its episode. */
224
+ export declare const CONTINUATION_WINDOW_MS: number;
225
+ /** The links a new prompt's task takes from the latest task of its session, or
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. */
229
+ export declare function continuationLinks(previous: ReportTask | null, nowMs?: number): {
230
+ continues: string;
231
+ episode: string;
232
+ } | null;
233
+ /** The most recent task of a session in this worktree, or null. */
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;
251
+ /** Every task of an episode, oldest first: the head and the prompts that continued it. */
252
+ export declare function episodeTasks(root: string, headId: string): ReportTask[];
204
253
  /** The record revisions among `records` that this task has not received before.
205
254
  * Powers the one-line "Hunch recalled …" indication on a lesson's FIRST delivery
206
255
  * in a task; repeats of the same revision stay silent (deduplicated per task and
@@ -223,8 +272,21 @@ export declare function recordReportRefusal(root: string, taskId: string, refusa
223
272
  export declare function recordReportConformance(root: string, taskId: string, conformance: ReportConformance): string;
224
273
  /** Only the local runner calls this. MCP never accepts a claimed successful check. */
225
274
  export declare function recordReportCheck(root: string, taskId: string, check: ReportCheck): string;
226
- export declare function beginReportCheck(root: string, taskId: string, label: string): string;
227
- export declare function finishReportTask(root: string, taskId: string, state?: "completed" | "interrupted"): ReportTask;
275
+ /** A start without a result blocks completion only while the runner could still
276
+ * deliver one: its own timeout plus a minute of grace. After that the runner is
277
+ * gone (a killed process, a closed laptop) and the report's unknowns already say
278
+ * the result was not retained; freezing the task forever would add nothing.
279
+ * Starts recorded before the timeout was retained use the verification ceiling. */
280
+ export declare const CHECK_RESULT_GRACE_MS = 60000;
281
+ export declare const MAX_PENDING_CHECK_MS: number;
282
+ export declare function beginReportCheck(root: string, taskId: string, label: string, timeoutMs?: number): string;
283
+ /** `by: "host"` is the lifecycle hook closing the prompt's task at Stop. It is
284
+ * provisional: a later observation reopens the task (see appendEvent) and an
285
+ * explicit agent finish, with any outcome, replaces it. Pending verification
286
+ * keeps the task open for either closer. */
287
+ export declare function finishReportTask(root: string, taskId: string, state?: "completed" | "interrupted", options?: {
288
+ by?: "agent" | "host";
289
+ }): ReportTask;
228
290
  /** A report with no observation of any kind. Presentation surfaces may stay
229
291
  * silent for it; the task row itself is retained so "never touched Hunch" is
230
292
  * still countable (hunch report / the VS Code view / task list). */
@@ -82,6 +82,19 @@ const TaskSchema = z.object({
82
82
  task_id: TaskIdSchema, scope: hashSchema, title: safeText(200),
83
83
  started_at: z.string().datetime(), finished_at: z.string().datetime().nullable(),
84
84
  state: z.enum(["open", "completed", "interrupted"]),
85
+ /** Who closed the task. "host": the lifecycle hook at Stop, a provisional
86
+ * close that a continuation reopens and an explicit agent finish overrides.
87
+ * Absent on rows written before this field existed (agent closes). */
88
+ closed_by: z.enum(["agent", "host"]).optional(),
89
+ /** Continuity across the prompts of one host session. `session_key` is a hash
90
+ * of (root, provider, session, agent), never the identifier itself; `continues`
91
+ * names the previous prompt's task when this prompt followed it within the
92
+ * continuation window; `episode` names the first task of that chain, the id
93
+ * the chain's graph record is written under. Absent on older rows and on
94
+ * tasks started without a host session (one task, one episode). */
95
+ session_key: hashSchema.optional(),
96
+ continues: TaskIdSchema.optional(),
97
+ episode: TaskIdSchema.optional(),
85
98
  }).strict();
86
99
  export const LessonReferenceSchema = z.object({
87
100
  kind: safeText(64), record_id: safeText(512), content_hash: hashSchema.optional(),
@@ -102,10 +115,25 @@ function taskDb(root, run) {
102
115
  );
103
116
  CREATE INDEX IF NOT EXISTS report_record_lookup ON report_record_links(kind, record_id, content_hash);
104
117
  CREATE TABLE IF NOT EXISTS report_history_progress (id INTEGER PRIMARY KEY CHECK(id = 1), through_rowid INTEGER NOT NULL);
105
- 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);`);
106
120
  return run(db);
107
121
  });
108
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
+ }
109
137
  function deliveryRecords(kind, body) {
110
138
  if (kind === "save")
111
139
  return [ReportSaveSchema.parse(body).record];
@@ -227,9 +255,9 @@ function transaction(db, run, readOnly = false) {
227
255
  throw error;
228
256
  }
229
257
  }
230
- export function startReportTask(root, title, taskId) {
258
+ export function startReportTask(root, title, taskId, links = {}) {
231
259
  const task = TaskSchema.parse({ task_id: taskId ?? `htask_${randomBytes(12).toString("hex")}`,
232
- scope: scopeOf(root), title, started_at: new Date().toISOString(), finished_at: null, state: "open" });
260
+ scope: scopeOf(root), title, started_at: new Date().toISOString(), finished_at: null, state: "open", ...links });
233
261
  // Local observations have a bounded lifetime; durable project memory is untouched.
234
262
  pruneReportHistory(root);
235
263
  return taskDb(root, db => transaction(db, () => {
@@ -244,6 +272,79 @@ export function startReportTask(root, title, taskId) {
244
272
  return task;
245
273
  }));
246
274
  }
275
+ /** A prompt that follows another in the same session within this window is the
276
+ * same work: its task continues the previous one and shares its episode. */
277
+ export const CONTINUATION_WINDOW_MS = 30 * 60_000;
278
+ /** The links a new prompt's task takes from the latest task of its session, or
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. */
282
+ export function continuationLinks(previous, nowMs = Date.now()) {
283
+ if (!previous)
284
+ return null;
285
+ const links = { continues: previous.task_id, episode: previous.episode ?? previous.task_id };
286
+ if (previous.state === "open")
287
+ return links;
288
+ const reference = Date.parse(previous.finished_at ?? previous.started_at);
289
+ if (!Number.isFinite(reference) || nowMs - reference > CONTINUATION_WINDOW_MS)
290
+ return null;
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;
296
+ }
297
+ /** The most recent task of a session in this worktree, or null. */
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 = []) {
334
+ return taskDb(root, db => {
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
+ });
342
+ });
343
+ }
344
+ /** Every task of an episode, oldest first: the head and the prompts that continued it. */
345
+ export function episodeTasks(root, headId) {
346
+ return taskDb(root, db => db.prepare("SELECT body FROM report_tasks WHERE task_id = ? OR json_extract(body, '$.episode') = ? ORDER BY rowid").all(headId, headId).map(r => TaskSchema.parse(JSON.parse(r.body))));
347
+ }
247
348
  function appendEvent(root, taskId, kind, body, eventId) {
248
349
  const encoded = JSON.stringify(body);
249
350
  if (Buffer.byteLength(encoded) > MAX_EVENT_BYTES)
@@ -260,30 +361,47 @@ function appendEvent(root, taskId, kind, body, eventId) {
260
361
  throw new Error("report event identity conflicts with existing evidence");
261
362
  return id;
262
363
  }
263
- if (task.state !== "open" && !(kind === "check" && task.state === "interrupted"))
264
- throw new Error("task is already closed; start a new task for new work");
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;
367
+ if (task.state !== "open" && !(kind === "check" && task.state === "interrupted")) {
368
+ if (task.closed_by !== "host")
369
+ throw new Error("task is already closed; start a new task for new work");
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
+ }
384
+ }
385
+ const tid = target.task_id;
265
386
  if (kind === "check") {
266
387
  const check = ReportCheckSchema.parse(body);
267
- 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))
268
389
  throw new Error("verification result has no matching start in this task");
269
390
  }
270
- const { total, bytes, pending } = db.prepare(`SELECT COUNT(*) AS total,
271
- COALESCE(SUM(length(CAST(body AS BLOB))), 0) AS bytes,
272
- SUM(CASE WHEN kind = 'check-start' THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending
273
- FROM report_events WHERE task_id = ?`).get(taskId);
274
- 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));
275
393
  if (total + 1 + reserved > MAX_EVENTS || bytes + Buffer.byteLength(encoded) + reserved * MAX_EVENT_BYTES > MAX_TASK_BYTES)
276
394
  throw new Error("task observation limit reached; start a new task");
277
395
  if (kind === "claim") {
278
396
  const claim = ReportClaimSchema.parse(body);
279
- 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);
280
398
  if (!row)
281
399
  throw new Error("claim does not refer to a delivery in this task");
282
400
  const delivery = JSON.parse(row.body);
283
401
  if (!delivery.records.some(r => r.record_id === claim.record_id && r.content_hash === claim.content_hash))
284
402
  throw new Error("claim record revision was not delivered in this task");
285
403
  }
286
- 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);
287
405
  indexDeliveryRecords(db, id, kind, body);
288
406
  // Advance only over contiguous observed inserts; an older writer may have
289
407
  // left unindexed events. Historical gaps are filled by bounded reads above.
@@ -361,23 +479,41 @@ export function recordReportCheck(root, taskId, check) {
361
479
  throw new Error("verification result requires a reserved check identity");
362
480
  return appendEvent(root, taskId, "check", value, `hev_${reportHash({ taskId, check: value.check_id }).slice(7, 31)}`);
363
481
  }
364
- export function beginReportCheck(root, taskId, label) {
365
- return appendEvent(root, taskId, "check-start", { label: safeText(200).parse(label) });
482
+ /** A start without a result blocks completion only while the runner could still
483
+ * deliver one: its own timeout plus a minute of grace. After that the runner is
484
+ * gone (a killed process, a closed laptop) and the report's unknowns already say
485
+ * the result was not retained; freezing the task forever would add nothing.
486
+ * Starts recorded before the timeout was retained use the verification ceiling. */
487
+ export const CHECK_RESULT_GRACE_MS = 60_000;
488
+ export const MAX_PENDING_CHECK_MS = 6 * 60 * 60_000;
489
+ export function beginReportCheck(root, taskId, label, timeoutMs) {
490
+ const timeout = Number.isInteger(timeoutMs) && timeoutMs > 0 ? { timeout_ms: timeoutMs } : {};
491
+ return appendEvent(root, taskId, "check-start", { label: safeText(200).parse(label), ...timeout });
366
492
  }
367
- export function finishReportTask(root, taskId, state = "completed") {
493
+ /** `by: "host"` is the lifecycle hook closing the prompt's task at Stop. It is
494
+ * provisional: a later observation reopens the task (see appendEvent) and an
495
+ * explicit agent finish, with any outcome, replaces it. Pending verification
496
+ * keeps the task open for either closer. */
497
+ export function finishReportTask(root, taskId, state = "completed", options = {}) {
498
+ const by = options.by ?? "agent";
368
499
  return taskDb(root, db => transaction(db, () => {
369
500
  const task = readTask(db, root, taskId);
370
501
  if (task.state !== "open") {
502
+ if (task.closed_by === "host" && by === "agent") {
503
+ const confirmed = TaskSchema.parse({ ...task, state, finished_at: task.state === state ? task.finished_at : new Date().toISOString(), closed_by: "agent" });
504
+ db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(confirmed), taskId);
505
+ return confirmed;
506
+ }
371
507
  if (task.state !== state)
372
508
  throw new Error("task already closed with a different outcome");
373
509
  return task;
374
510
  }
375
511
  if (state === "completed") {
376
- const { pending } = db.prepare(`SELECT SUM(CASE WHEN kind = 'check-start' 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);
512
+ 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);
377
513
  if ((pending ?? 0) > 0)
378
514
  throw new Error("verification is still running or was interrupted; wait for its result or close the task as interrupted");
379
515
  }
380
- const finished = TaskSchema.parse({ ...task, state, finished_at: new Date().toISOString() });
516
+ const finished = TaskSchema.parse({ ...task, state, finished_at: new Date().toISOString(), closed_by: by });
381
517
  db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(finished), taskId);
382
518
  return finished;
383
519
  }));
@@ -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. */
@@ -96,7 +96,7 @@ export function reportSourceSnapshot(root) {
96
96
  * `not-exercised` or `unavailable` — never "satisfied" by file overlap. */
97
97
  export function runReportConformance(root, store, taskId) {
98
98
  const report = readTaskReport(root, taskId);
99
- if (report.task.state !== "open")
99
+ if (report.task.state !== "open" && report.task.closed_by !== "host")
100
100
  throw new Error("cannot evaluate rules for a closed task");
101
101
  const delivered = [...new Map(report.deliveries.flatMap(d => d.records).filter(r => r.kind === "constraints" || r.kind === "decisions").map(r => [`${r.kind}:${r.record_id}:${r.content_hash}`, r])).values()];
102
102
  if (!delivered.length)
@@ -192,23 +192,26 @@ 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)
203
206
  throw new Error(`verification timeout must be between 1 and ${MAX_CHECK_TIMEOUT_MS} ms`);
204
207
  options.signal?.throwIfAborted();
205
208
  const task = readTaskReport(root, taskId).task;
206
- if (task.state !== "open")
209
+ if (task.state !== "open" && task.closed_by !== "host")
207
210
  throw new Error("cannot verify a closed task");
208
211
  const before = reportSourceSnapshot(root);
209
212
  // Validate sensitive arguments before executing or writing anything.
210
213
  ReportCheckSchema.parse({ label, command, exit_code: null, output_hash: reportHash(""), before_snapshot: before.hash, after_snapshot: null, snapshot_limitations: before.limitations, timed_out: false, source: "local-command-runner" });
211
- const checkId = beginReportCheck(root, taskId, label);
214
+ const checkId = beginReportCheck(root, taskId, label, timeoutMs);
212
215
  const result = await new Promise((resolveResult) => {
213
216
  // Windows launchers (npx.cmd, npm.cmd, other .cmd/.bat shims) cannot be spawned
214
217
  // without a shell; resolve them first so a check actually runs instead of
@@ -21,8 +21,30 @@ 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 presentation notice never denies Stop or injects another model turn. Stop
25
- * can precede another hook's continuation, so it does not close an open task.
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[];
36
+ /** Stop ends the turn, so the prompt's task closes here as a HOST close: the
37
+ * ledger says the task completed even when the agent never called finish, and
38
+ * a task with observations becomes a graph record without anyone's cooperation.
39
+ * The close is provisional because Stop can precede another hook's
40
+ * continuation: the next observation reopens the task and the following Stop
41
+ * closes it again (the record is refreshed from the report). An explicit agent
42
+ * finish with any outcome overrides a host close. Pending verification keeps
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[];
47
+ /** A presentation notice never denies Stop or injects another model turn.
26
48
  * A prompt with no observation at all prints nothing: the empty task row stays
27
49
  * in the ledger (hunch task list, the VS Code Contribution view) so "never
28
50
  * touched Hunch" remains countable without a five-line notice per prompt. */
@@ -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 { isEmptyTaskReport, 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
- return promptTaskId(root, event.session_id, event.prompt_id, event.agent_id ?? null, provider);
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 {
@@ -98,9 +105,33 @@ export function startHookReport(root, provider, event) {
98
105
  return null;
99
106
  const cwdLiteral = JSON.stringify(cwd);
100
107
  const title = (promptTitlesEnabled(root) ? promptTaskTitle(event.prompt) : null) ?? NATIVE_TASK_TITLE;
108
+ // Continuity: a prompt that follows another of the same session within the
109
+ // window continues its task ("status", "next", "go" are the same work), and
110
+ // the episode's graph record is written under the first task's id. The key
111
+ // is a hash; the host session identifier itself is still never retained.
112
+ let links = {};
113
+ const sessionKey = hookSessionKey(cwd, provider, event);
114
+ if (sessionKey) {
115
+ links = { session_key: sessionKey };
116
+ try {
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
+ }
126
+ const continued = previous && previous.task_id !== id ? continuationLinks(previous) : null;
127
+ if (continued)
128
+ links = { ...links, ...continued };
129
+ }
130
+ catch { /* no continuity; still a task */ }
131
+ }
101
132
  let task;
102
133
  try {
103
- task = startReportTask(root, title, id);
134
+ task = startReportTask(root, title, id, links);
104
135
  }
105
136
  catch (error) {
106
137
  // The same prompt identity may already be open: a release that called every
@@ -111,10 +142,78 @@ export function startHookReport(root, provider, event) {
111
142
  throw error;
112
143
  task = existing;
113
144
  }
145
+ return taskInstruction(task, cwdLiteral);
146
+ }
147
+ function taskInstruction(task, cwdLiteral) {
114
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.`;
115
149
  }
116
- /** A presentation notice never denies Stop or injects another model turn. Stop
117
- * can precede another hook's continuation, so it does not close an open task.
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
+ }
180
+ /** Stop ends the turn, so the prompt's task closes here as a HOST close: the
181
+ * ledger says the task completed even when the agent never called finish, and
182
+ * a task with observations becomes a graph record without anyone's cooperation.
183
+ * The close is provisional because Stop can precede another hook's
184
+ * continuation: the next observation reopens the task and the following Stop
185
+ * closes it again (the record is refreshed from the report). An explicit agent
186
+ * finish with any outcome overrides a host close. Pending verification keeps
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. */
190
+ export function closeHookTask(root, provider, event) {
191
+ let id;
192
+ try {
193
+ id = identity(root, provider, event);
194
+ }
195
+ catch {
196
+ return [];
197
+ }
198
+ if (!id || id === "legacy")
199
+ return [];
200
+ const closed = [];
201
+ try {
202
+ const task = readTaskReport(root, id).task;
203
+ if (task.state === "open")
204
+ finishReportTask(root, id, "completed", { by: "host" });
205
+ if (task.state !== "interrupted")
206
+ closed.push(id);
207
+ }
208
+ catch {
209
+ // No task for this prompt (a notification turn), or verification still running: leave it as it is.
210
+ }
211
+ for (const other of settleHookSession(root, provider, event, { keepId: id }))
212
+ if (!closed.includes(other))
213
+ closed.push(other);
214
+ return closed;
215
+ }
216
+ /** A presentation notice never denies Stop or injects another model turn.
118
217
  * A prompt with no observation at all prints nothing: the empty task row stays
119
218
  * in the ledger (hunch task list, the VS Code Contribution view) so "never
120
219
  * touched Hunch" remains countable without a five-line notice per prompt. */
@@ -0,0 +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>;
4
+ export declare function gitTouchedFiles(root: string, startedAt: string, finishedAt: string | null, options?: {
5
+ limit?: number;
6
+ timeoutMs?: number;
7
+ now?: number;
8
+ workingTree?: boolean;
9
+ }): string[];
@@ -0,0 +1,97 @@
1
+ /** Files the repository shows as worked on while a task was open.
2
+ *
3
+ * The pre-edit hook only sees edits made through an instrumented editor tool.
4
+ * Work done from a shell (patch scripts, rebases, release commits) never
5
+ * produced a delivery, so the task record had no file anchor for it and the
6
+ * ranking could not relate the task to later work on the same files. Two
7
+ * sources fill that gap, both bounded and fail-open (an error yields nothing,
8
+ * never a failed finish):
9
+ * - commits authored by the configured git user whose commit time falls in
10
+ * the task window (merges excluded);
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. */
22
+ import { execFileSync } from "node:child_process";
23
+ import { statSync } from "node:fs";
24
+ import { join } from "node:path";
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;
31
+ function gitDate(ms) {
32
+ // Second resolution, a format every git accepts.
33
+ return `${new Date(ms).toISOString().slice(0, 19).replace("T", " ")} +0000`;
34
+ }
35
+ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
36
+ const limit = Math.max(1, options.limit ?? 64);
37
+ const timeout = options.timeoutMs ?? 3_000;
38
+ const since = Date.parse(startedAt);
39
+ if (!Number.isFinite(since))
40
+ return [];
41
+ const until = finishedAt ? Date.parse(finishedAt) : (options.now ?? Date.now());
42
+ if (!Number.isFinite(until) || until < since)
43
+ return [];
44
+ // One second of slack on each side: git dates and some filesystems are second-granular.
45
+ const from = since - 1_000, to = until + 1_000;
46
+ const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
47
+ for (const key of Object.keys(env))
48
+ if (key.startsWith("GIT_") && key !== "GIT_OPTIONAL_LOCKS")
49
+ delete env[key];
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"] });
51
+ const out = new Set();
52
+ const normalize = (raw) => raw.trim().replace(/\\/g, "/").replace(/^\.\//, "");
53
+ const keep = (raw) => {
54
+ const path = normalize(raw);
55
+ if (!path || path.split("/").some((segment) => EXCLUDED_SEGMENTS.has(segment)))
56
+ return;
57
+ out.add(path);
58
+ };
59
+ try {
60
+ const email = run(["config", "--get", "user.email"]).trim();
61
+ if (email) {
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
+ }
71
+ }
72
+ }
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);
76
+ try {
77
+ const entries = run(["status", "--porcelain=v1", "-z", "--untracked-files=all"]).split("\0").filter(Boolean);
78
+ for (let i = 0; i < entries.length; i++) {
79
+ const entry = entries[i];
80
+ const code = entry.slice(0, 2), path = entry.slice(3);
81
+ // A rename or copy is followed by its original path as a separate entry.
82
+ if (code[0] === "R" || code[0] === "C")
83
+ i++;
84
+ if (code.includes("D") || !path || HUNCH_MANAGED_FILES.has(normalize(path)))
85
+ continue;
86
+ try {
87
+ const mtime = statSync(join(root, path)).mtimeMs;
88
+ if (mtime >= since && mtime <= to)
89
+ keep(path);
90
+ }
91
+ catch { /* vanished between status and stat */ }
92
+ }
93
+ }
94
+ catch { /* not a git worktree or status failed: nothing to add */ }
95
+ return [...out].sort().slice(0, limit);
96
+ }
97
+ //# sourceMappingURL=taskTouched.js.map
@@ -16,6 +16,10 @@ export declare function boundedTaskReport(report: ReturnType<typeof readTaskRepo
16
16
  started_at: string;
17
17
  finished_at: string | null;
18
18
  state: "open" | "completed" | "interrupted";
19
+ closed_by?: "host" | "agent" | undefined;
20
+ session_key?: string | undefined;
21
+ continues?: string | undefined;
22
+ episode?: string | undefined;
19
23
  };
20
24
  coverage: "no-delivery-observed" | "no-relevant-memory" | "delivered";
21
25
  content_hash: string;
@@ -105,6 +109,10 @@ export declare function boundedTaskReportForHost(report: ReturnType<typeof readT
105
109
  started_at: string;
106
110
  finished_at: string | null;
107
111
  state: "open" | "completed" | "interrupted";
112
+ closed_by?: "host" | "agent" | undefined;
113
+ session_key?: string | undefined;
114
+ continues?: string | undefined;
115
+ episode?: string | undefined;
108
116
  };
109
117
  coverage: "no-delivery-observed" | "no-relevant-memory" | "delivered";
110
118
  content_hash: string;
@@ -185,4 +193,13 @@ export declare function boundedTaskReportForHost(report: ReturnType<typeof readT
185
193
  };
186
194
  full_report: string;
187
195
  };
196
+ /** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
197
+ * loader; a published `.js` build needs nothing) and `resolve` is that
198
+ * module's `import.meta.resolve`. The loader is resolved ONLY on the source
199
+ * path: `import.meta.resolve` throws for a package that is not installed, and
200
+ * `tsx` is a devDependency absent from every published install (#261). */
201
+ export declare function verificationLauncherFor(metaUrl: string, resolve: (specifier: string) => string): {
202
+ argv: string[];
203
+ shell: string;
204
+ };
188
205
  export declare function registerTaskReportTools(server: McpServer, getRoot: () => string, getStore: () => HunchStore): void;
@@ -51,13 +51,21 @@ export function boundedTaskReportForHost(report) {
51
51
  /** Reuse the MCP server's installation, not a potentially stale global binary.
52
52
  * Structured argv is authoritative; the shell hint uses literal quoting. */
53
53
  function verificationLauncher() {
54
- const dev = import.meta.url.endsWith(".ts");
55
- const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, import.meta.url));
54
+ return verificationLauncherFor(import.meta.url, (specifier) => import.meta.resolve(specifier));
55
+ }
56
+ /** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
57
+ * loader; a published `.js` build needs nothing) and `resolve` is that
58
+ * module's `import.meta.resolve`. The loader is resolved ONLY on the source
59
+ * path: `import.meta.resolve` throws for a package that is not installed, and
60
+ * `tsx` is a devDependency absent from every published install (#261). */
61
+ export function verificationLauncherFor(metaUrl, resolve) {
62
+ const dev = metaUrl.endsWith(".ts");
63
+ const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, metaUrl));
56
64
  // `--import` takes a URL. Converting the resolved loader to a path made Node on
57
65
  // Windows reject it ("Received protocol 'c:'"), so every verification launched
58
66
  // from a source checkout there failed before running and cards showed no check.
59
- const loader = import.meta.resolve("tsx");
60
- const argv = [process.execPath, ...(dev ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
67
+ const loader = dev ? resolve("tsx") : null;
68
+ const argv = [process.execPath, ...(loader ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
61
69
  const quote = (s) => process.platform === "win32" ? `'${s.replace(/'/g, "''")}'` : `'${s.replace(/'/g, "'\\''")}'`;
62
70
  return { argv, shell: `${process.platform === "win32" ? "& " : ""}${argv.map(quote).join(" ")}` };
63
71
  }
@@ -91,7 +99,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
91
99
  task = readTaskReport(root, task_id, reportSourceSnapshot(root).hash).task;
92
100
  }
93
101
  const launcher = verificationLauncher();
94
- return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]. The default budget is 2 minutes; add --timeout <seconds> before -- for a long suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
102
+ return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]. The default budget is 15 minutes; add --timeout <seconds> before -- for a longer suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
95
103
  }
96
104
  if (!task_id)
97
105
  throw new Error("finish requires the exact task_id");
@@ -21,6 +21,10 @@ export declare function createTaskReporter(root: string): {
21
21
  started_at: string;
22
22
  finished_at: string | null;
23
23
  state: "open" | "completed" | "interrupted";
24
+ closed_by?: "host" | "agent" | undefined;
25
+ session_key?: string | undefined;
26
+ continues?: string | undefined;
27
+ episode?: string | undefined;
24
28
  };
25
29
  /** The caller supplies the exact envelope it issued plus snapshots of the
26
30
  * included revisions. Return the occurrence with the context to the agent.
@@ -69,6 +73,10 @@ export declare function createTaskReporter(root: string): {
69
73
  started_at: string;
70
74
  finished_at: string | null;
71
75
  state: "open" | "completed" | "interrupted";
76
+ closed_by?: "host" | "agent" | undefined;
77
+ session_key?: string | undefined;
78
+ continues?: string | undefined;
79
+ episode?: string | undefined;
72
80
  }[];
73
81
  lesson: (reference: LessonReference, options?: {
74
82
  limit?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.37.1",
3
+ "version": "1.38.1",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.37.1",
10
+ "version": "1.38.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.37.1",
16
+ "version": "1.38.1",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {