@davesheffer/hunch 1.37.1 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js 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, 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,16 @@ 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) {
4557
+ store ??= new HunchStore(paths);
4558
+ persistTaskRecord(root, store, closed);
4559
+ }
4560
+ }
4561
+ catch { /* the ledger and the card remain authoritative; the next finish retries */ }
4551
4562
  const report = stopHookReport(root, provider, evt);
4552
4563
  if (report)
4553
4564
  console.log(JSON.stringify(report));
@@ -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 } 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,35 @@ 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
+ let built = taskRecordFromReports(reports, gitTouchedFiles(root, window.from, window.to));
150
188
  if (!built)
151
189
  return null;
190
+ let inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
191
+ let inPublic = store.json.get("tasks", built.id);
192
+ // A record never changes home. If the episode's record already lives in the
193
+ // public store and this prompt brought private-only memory into it, the
194
+ // episode splits here: this prompt keeps its own record instead of naming
195
+ // private memory in a public one.
196
+ if (inPublic && !inPrivate && taskRecordHome(store, built) === "private" && built.id !== taskId) {
197
+ built = taskRecordFromReports([own], gitTouchedFiles(root, own.task.started_at, own.task.finished_at));
198
+ if (!built)
199
+ return null;
200
+ inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
201
+ inPublic = store.json.get("tasks", built.id);
202
+ }
152
203
  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
204
  const home = inPrivate ? "private" : inPublic ? "public" : taskRecordHome(store, record);
156
205
  const existing = home === "private" ? inPrivate : inPublic;
157
206
  if (existing && existing.report_hash === record.report_hash)
@@ -202,7 +251,10 @@ export function mergeDurableTaskSummaries(store, summaries, limit = 30) {
202
251
  const seen = new Set();
203
252
  const merged = summaries.map((s) => {
204
253
  seen.add(s.task.task_id);
205
- const home = homes.get(s.task.task_id);
254
+ // A continued prompt's record lives under its episode head.
255
+ const home = homes.get(s.task.task_id) ?? homes.get(s.task.episode ?? "");
256
+ if (s.task.episode)
257
+ seen.add(s.task.episode);
206
258
  return { ...s, durable: home ? { home } : null };
207
259
  });
208
260
  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 {
@@ -200,7 +207,26 @@ export declare function readLessonHistory(root: string, reference: LessonReferen
200
207
  limit?: number;
201
208
  before?: number;
202
209
  }): LessonHistory;
203
- export declare function startReportTask(root: string, title: string, taskId?: string): ReportTask;
210
+ export interface TaskLinks {
211
+ session_key?: string;
212
+ continues?: string;
213
+ episode?: string;
214
+ }
215
+ export declare function startReportTask(root: string, title: string, taskId?: string, links?: TaskLinks): ReportTask;
216
+ /** A prompt that follows another in the same session within this window is the
217
+ * same work: its task continues the previous one and shares its episode. */
218
+ export declare const CONTINUATION_WINDOW_MS: number;
219
+ /** 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. */
222
+ export declare function continuationLinks(previous: ReportTask | null, nowMs?: number): {
223
+ continues: string;
224
+ episode: string;
225
+ } | null;
226
+ /** The most recent task of a session in this worktree, or null. */
227
+ export declare function latestSessionTask(root: string, sessionKey: string): ReportTask | null;
228
+ /** Every task of an episode, oldest first: the head and the prompts that continued it. */
229
+ export declare function episodeTasks(root: string, headId: string): ReportTask[];
204
230
  /** The record revisions among `records` that this task has not received before.
205
231
  * Powers the one-line "Hunch recalled …" indication on a lesson's FIRST delivery
206
232
  * in a task; repeats of the same revision stay silent (deduplicated per task and
@@ -223,8 +249,21 @@ export declare function recordReportRefusal(root: string, taskId: string, refusa
223
249
  export declare function recordReportConformance(root: string, taskId: string, conformance: ReportConformance): string;
224
250
  /** Only the local runner calls this. MCP never accepts a claimed successful check. */
225
251
  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;
252
+ /** A start without a result blocks completion only while the runner could still
253
+ * deliver one: its own timeout plus a minute of grace. After that the runner is
254
+ * gone (a killed process, a closed laptop) and the report's unknowns already say
255
+ * the result was not retained; freezing the task forever would add nothing.
256
+ * Starts recorded before the timeout was retained use the verification ceiling. */
257
+ export declare const CHECK_RESULT_GRACE_MS = 60000;
258
+ export declare const MAX_PENDING_CHECK_MS: number;
259
+ export declare function beginReportCheck(root: string, taskId: string, label: string, timeoutMs?: number): string;
260
+ /** `by: "host"` is the lifecycle hook closing the prompt's task at Stop. It is
261
+ * provisional: a later observation reopens the task (see appendEvent) and an
262
+ * explicit agent finish, with any outcome, replaces it. Pending verification
263
+ * keeps the task open for either closer. */
264
+ export declare function finishReportTask(root: string, taskId: string, state?: "completed" | "interrupted", options?: {
265
+ by?: "agent" | "host";
266
+ }): ReportTask;
228
267
  /** A report with no observation of any kind. Presentation surfaces may stay
229
268
  * silent for it; the task row itself is retained so "never touched Hunch" is
230
269
  * 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(),
@@ -227,9 +240,9 @@ function transaction(db, run, readOnly = false) {
227
240
  throw error;
228
241
  }
229
242
  }
230
- export function startReportTask(root, title, taskId) {
243
+ export function startReportTask(root, title, taskId, links = {}) {
231
244
  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" });
245
+ scope: scopeOf(root), title, started_at: new Date().toISOString(), finished_at: null, state: "open", ...links });
233
246
  // Local observations have a bounded lifetime; durable project memory is untouched.
234
247
  pruneReportHistory(root);
235
248
  return taskDb(root, db => transaction(db, () => {
@@ -244,6 +257,31 @@ export function startReportTask(root, title, taskId) {
244
257
  return task;
245
258
  }));
246
259
  }
260
+ /** A prompt that follows another in the same session within this window is the
261
+ * same work: its task continues the previous one and shares its episode. */
262
+ export const CONTINUATION_WINDOW_MS = 30 * 60_000;
263
+ /** 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. */
266
+ export function continuationLinks(previous, nowMs = Date.now()) {
267
+ if (!previous)
268
+ return null;
269
+ const reference = Date.parse(previous.finished_at ?? previous.started_at);
270
+ if (!Number.isFinite(reference) || nowMs - reference > CONTINUATION_WINDOW_MS)
271
+ return null;
272
+ return { continues: previous.task_id, episode: previous.episode ?? previous.task_id };
273
+ }
274
+ /** The most recent task of a session in this worktree, or null. */
275
+ export function latestSessionTask(root, sessionKey) {
276
+ 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;
279
+ });
280
+ }
281
+ /** Every task of an episode, oldest first: the head and the prompts that continued it. */
282
+ export function episodeTasks(root, headId) {
283
+ 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))));
284
+ }
247
285
  function appendEvent(root, taskId, kind, body, eventId) {
248
286
  const encoded = JSON.stringify(body);
249
287
  if (Buffer.byteLength(encoded) > MAX_EVENT_BYTES)
@@ -260,8 +298,14 @@ function appendEvent(root, taskId, kind, body, eventId) {
260
298
  throw new Error("report event identity conflicts with existing evidence");
261
299
  return id;
262
300
  }
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");
301
+ if (task.state !== "open" && !(kind === "check" && task.state === "interrupted")) {
302
+ if (task.closed_by !== "host")
303
+ 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);
308
+ }
265
309
  if (kind === "check") {
266
310
  const check = ReportCheckSchema.parse(body);
267
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))
@@ -269,7 +313,7 @@ function appendEvent(root, taskId, kind, body, eventId) {
269
313
  }
270
314
  const { total, bytes, pending } = db.prepare(`SELECT COUNT(*) AS total,
271
315
  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
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
273
317
  FROM report_events WHERE task_id = ?`).get(taskId);
274
318
  const reserved = Math.max(0, (pending ?? 0) + (kind === "check-start" ? 1 : kind === "check" ? -1 : 0));
275
319
  if (total + 1 + reserved > MAX_EVENTS || bytes + Buffer.byteLength(encoded) + reserved * MAX_EVENT_BYTES > MAX_TASK_BYTES)
@@ -361,23 +405,41 @@ export function recordReportCheck(root, taskId, check) {
361
405
  throw new Error("verification result requires a reserved check identity");
362
406
  return appendEvent(root, taskId, "check", value, `hev_${reportHash({ taskId, check: value.check_id }).slice(7, 31)}`);
363
407
  }
364
- export function beginReportCheck(root, taskId, label) {
365
- return appendEvent(root, taskId, "check-start", { label: safeText(200).parse(label) });
408
+ /** A start without a result blocks completion only while the runner could still
409
+ * deliver one: its own timeout plus a minute of grace. After that the runner is
410
+ * gone (a killed process, a closed laptop) and the report's unknowns already say
411
+ * the result was not retained; freezing the task forever would add nothing.
412
+ * Starts recorded before the timeout was retained use the verification ceiling. */
413
+ export const CHECK_RESULT_GRACE_MS = 60_000;
414
+ export const MAX_PENDING_CHECK_MS = 6 * 60 * 60_000;
415
+ export function beginReportCheck(root, taskId, label, timeoutMs) {
416
+ const timeout = Number.isInteger(timeoutMs) && timeoutMs > 0 ? { timeout_ms: timeoutMs } : {};
417
+ return appendEvent(root, taskId, "check-start", { label: safeText(200).parse(label), ...timeout });
366
418
  }
367
- export function finishReportTask(root, taskId, state = "completed") {
419
+ /** `by: "host"` is the lifecycle hook closing the prompt's task at Stop. It is
420
+ * provisional: a later observation reopens the task (see appendEvent) and an
421
+ * explicit agent finish, with any outcome, replaces it. Pending verification
422
+ * keeps the task open for either closer. */
423
+ export function finishReportTask(root, taskId, state = "completed", options = {}) {
424
+ const by = options.by ?? "agent";
368
425
  return taskDb(root, db => transaction(db, () => {
369
426
  const task = readTask(db, root, taskId);
370
427
  if (task.state !== "open") {
428
+ if (task.closed_by === "host" && by === "agent") {
429
+ const confirmed = TaskSchema.parse({ ...task, state, finished_at: task.state === state ? task.finished_at : new Date().toISOString(), closed_by: "agent" });
430
+ db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(confirmed), taskId);
431
+ return confirmed;
432
+ }
371
433
  if (task.state !== state)
372
434
  throw new Error("task already closed with a different outcome");
373
435
  return task;
374
436
  }
375
437
  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);
438
+ 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
439
  if ((pending ?? 0) > 0)
378
440
  throw new Error("verification is still running or was interrupted; wait for its result or close the task as interrupted");
379
441
  }
380
- const finished = TaskSchema.parse({ ...task, state, finished_at: new Date().toISOString() });
442
+ const finished = TaskSchema.parse({ ...task, state, finished_at: new Date().toISOString(), closed_by: by });
381
443
  db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(finished), taskId);
382
444
  return finished;
383
445
  }));
@@ -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)
@@ -203,12 +203,12 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = 1
203
203
  throw new Error(`verification timeout must be between 1 and ${MAX_CHECK_TIMEOUT_MS} ms`);
204
204
  options.signal?.throwIfAborted();
205
205
  const task = readTaskReport(root, taskId).task;
206
- if (task.state !== "open")
206
+ if (task.state !== "open" && task.closed_by !== "host")
207
207
  throw new Error("cannot verify a closed task");
208
208
  const before = reportSourceSnapshot(root);
209
209
  // Validate sensitive arguments before executing or writing anything.
210
210
  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);
211
+ const checkId = beginReportCheck(root, taskId, label, timeoutMs);
212
212
  const result = await new Promise((resolveResult) => {
213
213
  // Windows launchers (npx.cmd, npm.cmd, other .cmd/.bat shims) cannot be spawned
214
214
  // without a shell; resolve them first so a check actually runs instead of
@@ -21,8 +21,17 @@ 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
+ /** Stop ends the turn, so the prompt's task closes here as a HOST close: the
25
+ * ledger says the task completed even when the agent never called finish, and
26
+ * a task with observations becomes a graph record without anyone's cooperation.
27
+ * The close is provisional because Stop can precede another hook's
28
+ * continuation: the next observation reopens the task and the following Stop
29
+ * closes it again (the record is refreshed from the report). An explicit agent
30
+ * 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;
34
+ /** A presentation notice never denies Stop or injects another model turn.
26
35
  * A prompt with no observation at all prints nothing: the empty task row stays
27
36
  * in the ledger (hunch task list, the VS Code Contribution view) so "never
28
37
  * 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 { continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, 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. */
@@ -98,9 +98,25 @@ export function startHookReport(root, provider, event) {
98
98
  return null;
99
99
  const cwdLiteral = JSON.stringify(cwd);
100
100
  const title = (promptTitlesEnabled(root) ? promptTaskTitle(event.prompt) : null) ?? NATIVE_TASK_TITLE;
101
+ // Continuity: a prompt that follows another of the same session within the
102
+ // window continues its task ("status", "next", "go" are the same work), and
103
+ // the episode's graph record is written under the first task's id. The key
104
+ // is a hash; the host session identifier itself is still never retained.
105
+ let links = {};
106
+ if (event.session_id) {
107
+ const sessionKey = reportHash([cwd, provider, event.session_id, event.agent_id ?? null]);
108
+ links = { session_key: sessionKey };
109
+ try {
110
+ const previous = latestSessionTask(root, sessionKey);
111
+ const continued = previous && previous.task_id !== id ? continuationLinks(previous) : null;
112
+ if (continued)
113
+ links = { ...links, ...continued };
114
+ }
115
+ catch { /* no continuity; still a task */ }
116
+ }
101
117
  let task;
102
118
  try {
103
- task = startReportTask(root, title, id);
119
+ task = startReportTask(root, title, id, links);
104
120
  }
105
121
  catch (error) {
106
122
  // The same prompt identity may already be open: a release that called every
@@ -113,8 +129,39 @@ export function startHookReport(root, provider, event) {
113
129
  }
114
130
  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
131
  }
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.
132
+ /** Stop ends the turn, so the prompt's task closes here as a HOST close: the
133
+ * ledger says the task completed even when the agent never called finish, and
134
+ * a task with observations becomes a graph record without anyone's cooperation.
135
+ * The close is provisional because Stop can precede another hook's
136
+ * continuation: the next observation reopens the task and the following Stop
137
+ * closes it again (the record is refreshed from the report). An explicit agent
138
+ * finish with any outcome overrides a host close. Pending verification keeps
139
+ * the task open. Returns the task id when the task is closed after this call,
140
+ * so the caller can persist its record; null when nothing is closed. */
141
+ export function closeHookTask(root, provider, event) {
142
+ let id;
143
+ try {
144
+ id = identity(root, provider, event);
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ if (!id || id === "legacy")
150
+ return null;
151
+ try {
152
+ const task = readTaskReport(root, id).task;
153
+ if (task.state === "interrupted")
154
+ return null;
155
+ if (task.state === "open")
156
+ finishReportTask(root, id, "completed", { by: "host" });
157
+ return id;
158
+ }
159
+ catch {
160
+ // No task for this prompt, or verification still running: leave it as it is.
161
+ return null;
162
+ }
163
+ }
164
+ /** A presentation notice never denies Stop or injects another model turn.
118
165
  * A prompt with no observation at all prints nothing: the empty task row stays
119
166
  * in the ledger (hunch task list, the VS Code Contribution view) so "never
120
167
  * touched Hunch" remains countable without a five-line notice per prompt. */
@@ -0,0 +1,5 @@
1
+ export declare function gitTouchedFiles(root: string, startedAt: string, finishedAt: string | null, options?: {
2
+ limit?: number;
3
+ timeoutMs?: number;
4
+ now?: number;
5
+ }): string[];
@@ -0,0 +1,77 @@
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
+ * Hunch's own memory and cache paths are excluded, so a capture commit made
13
+ * during the task does not count as work on a file. Deleted paths are skipped:
14
+ * nothing dates the deletion. Commit dates get one second of slack (git keeps
15
+ * seconds); working-tree mtimes get none before the start. */
16
+ import { execFileSync } from "node:child_process";
17
+ import { statSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ const EXCLUDED_SEGMENTS = new Set([".hunch", ".hunch-cache", ".git"]);
20
+ function gitDate(ms) {
21
+ // Second resolution, a format every git accepts.
22
+ return `${new Date(ms).toISOString().slice(0, 19).replace("T", " ")} +0000`;
23
+ }
24
+ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
25
+ const limit = Math.max(1, options.limit ?? 64);
26
+ const timeout = options.timeoutMs ?? 3_000;
27
+ const since = Date.parse(startedAt);
28
+ if (!Number.isFinite(since))
29
+ return [];
30
+ const until = finishedAt ? Date.parse(finishedAt) : (options.now ?? Date.now());
31
+ if (!Number.isFinite(until) || until < since)
32
+ return [];
33
+ // One second of slack on each side: git dates and some filesystems are second-granular.
34
+ const from = since - 1_000, to = until + 1_000;
35
+ const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
36
+ for (const key of Object.keys(env))
37
+ if (key.startsWith("GIT_") && key !== "GIT_OPTIONAL_LOCKS")
38
+ delete env[key];
39
+ const run = (args) => execFileSync("git", ["-C", root, "-c", "core.quotePath=false", ...args], { env, encoding: "utf8", timeout, maxBuffer: 4_000_000, stdio: ["ignore", "pipe", "ignore"] });
40
+ const out = new Set();
41
+ const keep = (raw) => {
42
+ const path = raw.trim().replace(/\\/g, "/").replace(/^\.\//, "");
43
+ if (!path || path.split("/").some((segment) => EXCLUDED_SEGMENTS.has(segment)))
44
+ return;
45
+ out.add(path);
46
+ };
47
+ try {
48
+ const email = run(["config", "--get", "user.email"]).trim();
49
+ if (email) {
50
+ const log = run(["log", "--no-merges", "-n", "50", `--since=${gitDate(from)}`, `--until=${gitDate(to)}`, `--author=${email}`, "--format=", "--name-only"]);
51
+ for (const line of log.split("\n"))
52
+ keep(line);
53
+ }
54
+ }
55
+ catch { /* no commits, no git user, or no git: the working tree may still say something */ }
56
+ try {
57
+ const entries = run(["status", "--porcelain=v1", "-z", "--untracked-files=all"]).split("\0").filter(Boolean);
58
+ for (let i = 0; i < entries.length; i++) {
59
+ const entry = entries[i];
60
+ const code = entry.slice(0, 2), path = entry.slice(3);
61
+ // A rename or copy is followed by its original path as a separate entry.
62
+ if (code[0] === "R" || code[0] === "C")
63
+ i++;
64
+ if (code.includes("D") || !path)
65
+ continue;
66
+ try {
67
+ const mtime = statSync(join(root, path)).mtimeMs;
68
+ if (mtime >= since && mtime <= to)
69
+ keep(path);
70
+ }
71
+ catch { /* vanished between status and stat */ }
72
+ }
73
+ }
74
+ catch { /* not a git worktree or status failed: nothing to add */ }
75
+ return [...out].sort().slice(0, limit);
76
+ }
77
+ //# 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;
@@ -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.0",
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.0",
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.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {