@davesheffer/hunch 1.38.0 → 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,7 @@ 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 { closeHookTask, hookReportTaskId, nativeHookCwd, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
91
+ import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
92
92
  import { persistTaskRecord } from "../core/taskRecord.js";
93
93
  import { recordHookObservation } from "../core/hookObservations.js";
94
94
  import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
@@ -4553,9 +4553,10 @@ program
4553
4553
  // or not the agent called finish. Fail-open: the card below still renders.
4554
4554
  try {
4555
4555
  const closed = closeHookTask(root, provider, evt);
4556
- if (closed) {
4556
+ if (closed.length) {
4557
4557
  store ??= new HunchStore(paths);
4558
- persistTaskRecord(root, store, closed);
4558
+ for (const id of closed)
4559
+ persistTaskRecord(root, store, id);
4559
4560
  }
4560
4561
  }
4561
4562
  catch { /* the ledger and the card remain authoritative; the next finish retries */ }
@@ -4586,6 +4587,17 @@ program
4586
4587
  }
4587
4588
  }
4588
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 */ }
4589
4601
  // Pipeline turn bookkeeping (fresh block budget) + the one nag that must
4590
4602
  // repeat: edits from an earlier turn still unverified.
4591
4603
  if (evt.session_id && pipelineEnabled()) {
@@ -14,7 +14,7 @@ import { readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { flushCapture } from "../integrations/sync.js";
16
16
  import { hunchPaths } from "./paths.js";
17
- import { episodeTasks, isEmptyTaskReport, readTaskReport, reportHash } from "./taskReport.js";
17
+ import { episodeTasks, isEmptyTaskReport, readTaskReport, reportHash, sessionsOverlap } from "./taskReport.js";
18
18
  import { reportSourceSnapshot } from "./taskReportEvidence.js";
19
19
  import { ENTITY_KINDS, TaskRecordSchema } from "./types.js";
20
20
  import { refreshRankEval } from "./taskRankingMode.js";
@@ -184,7 +184,10 @@ export function persistTaskRecord(root, store, taskId, options = {}) {
184
184
  const members = headId === own.task.task_id && !own.task.episode ? [own.task] : episodeTasks(root, headId);
185
185
  const reports = (members.length ? members : [own.task]).map((t) => (t.task_id === taskId ? own : readTaskReport(root, t.task_id, snapshot)));
186
186
  const window = { from: reports[0].task.started_at, to: reports.reduce((max, r) => (r.task.finished_at && (!max || r.task.finished_at > max) ? r.task.finished_at : max), null) };
187
- let built = taskRecordFromReports(reports, gitTouchedFiles(root, window.from, window.to));
187
+ // Another session working in the same checkout at the same time leaves the
188
+ // same mtimes: then only commits (attributable by author and time) count.
189
+ const workingTree = !sessionsOverlap(root, reports[0].task.session_key, window.from, window.to, reports.map((r) => r.task.task_id));
190
+ let built = taskRecordFromReports(reports, gitTouchedFiles(root, window.from, window.to, { workingTree }));
188
191
  if (!built)
189
192
  return null;
190
193
  let inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
@@ -194,7 +197,7 @@ export function persistTaskRecord(root, store, taskId, options = {}) {
194
197
  // episode splits here: this prompt keeps its own record instead of naming
195
198
  // private memory in a public one.
196
199
  if (inPublic && !inPrivate && taskRecordHome(store, built) === "private" && built.id !== taskId) {
197
- built = taskRecordFromReports([own], gitTouchedFiles(root, own.task.started_at, own.task.finished_at));
200
+ built = taskRecordFromReports([own], gitTouchedFiles(root, own.task.started_at, own.task.finished_at, { workingTree }));
198
201
  if (!built)
199
202
  return null;
200
203
  inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
@@ -200,6 +200,12 @@ export interface LessonHistory {
200
200
  truncated: boolean;
201
201
  next_before: number | null;
202
202
  }
203
+ /** A prompt identity that reports to another prompt's task: a host notification
204
+ * turn continues the session's latest task instead of opening a row. Explicit,
205
+ * so Stop never selects a task by recency for a prompt it does not know. */
206
+ export declare function aliasReportTask(root: string, aliasId: string, taskId: string): void;
207
+ /** The task an aliased prompt identity reports to, or the identity itself. */
208
+ export declare function resolveReportTask(root: string, id: string): string;
203
209
  /** The lookup is derived, never evidence. Backfill at most 64 events and
204
210
  * 512 KB per read. Parse outside the writer transaction so a history view cannot
205
211
  * hold the writer lock while validating a large legacy ledger. */
@@ -217,14 +223,31 @@ export declare function startReportTask(root: string, title: string, taskId?: st
217
223
  * same work: its task continues the previous one and shares its episode. */
218
224
  export declare const CONTINUATION_WINDOW_MS: number;
219
225
  /** The links a new prompt's task takes from the latest task of its session, or
220
- * null when that task is too old (measured from its close, or its start when it
221
- * was never closed) to be the same work. */
226
+ * null when that task is too old (measured from its close) to be the same work.
227
+ * A task still open is the session's current work however long ago it started:
228
+ * the prompt was interrupted before Stop, or a late observation reopened it. */
222
229
  export declare function continuationLinks(previous: ReportTask | null, nowMs?: number): {
223
230
  continues: string;
224
231
  episode: string;
225
232
  } | null;
226
233
  /** The most recent task of a session in this worktree, or null. */
227
234
  export declare function latestSessionTask(root: string, sessionKey: string): ReportTask | null;
235
+ /** Tasks of a session that an earlier prompt left open are over once the
236
+ * session moves on: the prompt was interrupted before its Stop, a notification
237
+ * turn reused the task, or a late observation reopened it. Close them as host
238
+ * closes, except `keepId` (the prompt now running), the session's newest task
239
+ * when `keepNewest` (a notification turn continues it), and any task whose
240
+ * verification may still deliver a result. Returns the ids closed here so the
241
+ * caller can persist their records. */
242
+ export declare function settleSessionTasks(root: string, sessionKey: string, options?: {
243
+ keepId?: string | null;
244
+ keepNewest?: boolean;
245
+ }): string[];
246
+ /** Whether a task of ANOTHER session (or of no session) was open in this
247
+ * worktree during the window: working-tree edits made then cannot be told apart
248
+ * by mtime, so the caller attributes only commits. `ownIds` are the episode's
249
+ * own tasks; any other task without a session key counts as foreign. */
250
+ export declare function sessionsOverlap(root: string, sessionKey: string | undefined, from: string, to: string | null, ownIds?: readonly string[]): boolean;
228
251
  /** Every task of an episode, oldest first: the head and the prompts that continued it. */
229
252
  export declare function episodeTasks(root: string, headId: string): ReportTask[];
230
253
  /** The record revisions among `records` that this task has not received before.
@@ -115,10 +115,25 @@ function taskDb(root, run) {
115
115
  );
116
116
  CREATE INDEX IF NOT EXISTS report_record_lookup ON report_record_links(kind, record_id, content_hash);
117
117
  CREATE TABLE IF NOT EXISTS report_history_progress (id INTEGER PRIMARY KEY CHECK(id = 1), through_rowid INTEGER NOT NULL);
118
- INSERT OR IGNORE INTO report_history_progress VALUES (1, 0);`);
118
+ INSERT OR IGNORE INTO report_history_progress VALUES (1, 0);
119
+ CREATE TABLE IF NOT EXISTS report_task_aliases (alias_id TEXT PRIMARY KEY, task_id TEXT NOT NULL);`);
119
120
  return run(db);
120
121
  });
121
122
  }
123
+ /** A prompt identity that reports to another prompt's task: a host notification
124
+ * turn continues the session's latest task instead of opening a row. Explicit,
125
+ * so Stop never selects a task by recency for a prompt it does not know. */
126
+ export function aliasReportTask(root, aliasId, taskId) {
127
+ TaskIdSchema.parse(aliasId);
128
+ taskDb(root, db => transaction(db, () => {
129
+ readTask(db, root, taskId);
130
+ db.prepare("INSERT OR REPLACE INTO report_task_aliases VALUES (?, ?)").run(aliasId, taskId);
131
+ }));
132
+ }
133
+ /** The task an aliased prompt identity reports to, or the identity itself. */
134
+ export function resolveReportTask(root, id) {
135
+ return taskDb(root, db => db.prepare("SELECT task_id FROM report_task_aliases WHERE alias_id = ?").get(id)?.task_id ?? id);
136
+ }
122
137
  function deliveryRecords(kind, body) {
123
138
  if (kind === "save")
124
139
  return [ReportSaveSchema.parse(body).record];
@@ -261,21 +276,69 @@ export function startReportTask(root, title, taskId, links = {}) {
261
276
  * same work: its task continues the previous one and shares its episode. */
262
277
  export const CONTINUATION_WINDOW_MS = 30 * 60_000;
263
278
  /** The links a new prompt's task takes from the latest task of its session, or
264
- * null when that task is too old (measured from its close, or its start when it
265
- * was never closed) to be the same work. */
279
+ * null when that task is too old (measured from its close) to be the same work.
280
+ * A task still open is the session's current work however long ago it started:
281
+ * the prompt was interrupted before Stop, or a late observation reopened it. */
266
282
  export function continuationLinks(previous, nowMs = Date.now()) {
267
283
  if (!previous)
268
284
  return null;
285
+ const links = { continues: previous.task_id, episode: previous.episode ?? previous.task_id };
286
+ if (previous.state === "open")
287
+ return links;
269
288
  const reference = Date.parse(previous.finished_at ?? previous.started_at);
270
289
  if (!Number.isFinite(reference) || nowMs - reference > CONTINUATION_WINDOW_MS)
271
290
  return null;
272
- return { continues: previous.task_id, episode: previous.episode ?? previous.task_id };
291
+ return links;
292
+ }
293
+ function newestSessionTask(db, root, sessionKey) {
294
+ const row = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.session_key') = ? ORDER BY rowid DESC LIMIT 1").get(...scopePair(root), sessionKey);
295
+ return row ? TaskSchema.parse(JSON.parse(row.body)) : null;
273
296
  }
274
297
  /** The most recent task of a session in this worktree, or null. */
275
298
  export function latestSessionTask(root, sessionKey) {
299
+ return taskDb(root, db => newestSessionTask(db, root, sessionKey));
300
+ }
301
+ /** Check-starts still inside their own timeout plus the grace window, minus the
302
+ * results that arrived: while positive, a runner may still deliver a result. */
303
+ function pendingChecks(db, taskId) {
304
+ const { pending } = db.prepare(`SELECT SUM(CASE WHEN kind = 'check-start' AND (julianday('now') - julianday(at)) * 86400000 < COALESCE(json_extract(body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending FROM report_events WHERE task_id = ?`).get(taskId);
305
+ return pending ?? 0;
306
+ }
307
+ /** Tasks of a session that an earlier prompt left open are over once the
308
+ * session moves on: the prompt was interrupted before its Stop, a notification
309
+ * turn reused the task, or a late observation reopened it. Close them as host
310
+ * closes, except `keepId` (the prompt now running), the session's newest task
311
+ * when `keepNewest` (a notification turn continues it), and any task whose
312
+ * verification may still deliver a result. Returns the ids closed here so the
313
+ * caller can persist their records. */
314
+ export function settleSessionTasks(root, sessionKey, options = {}) {
315
+ return taskDb(root, db => transaction(db, () => {
316
+ const rows = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.session_key') = ? AND json_extract(body, '$.state') = 'open' ORDER BY rowid").all(...scopePair(root), sessionKey);
317
+ const newest = options.keepNewest ? newestSessionTask(db, root, sessionKey)?.task_id : undefined;
318
+ const closed = [];
319
+ for (const row of rows) {
320
+ const task = TaskSchema.parse(JSON.parse(row.body));
321
+ if (task.task_id === options.keepId || task.task_id === newest || pendingChecks(db, task.task_id) > 0)
322
+ continue;
323
+ db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(TaskSchema.parse({ ...task, state: "completed", finished_at: new Date().toISOString(), closed_by: "host" })), task.task_id);
324
+ closed.push(task.task_id);
325
+ }
326
+ return closed;
327
+ }));
328
+ }
329
+ /** Whether a task of ANOTHER session (or of no session) was open in this
330
+ * worktree during the window: working-tree edits made then cannot be told apart
331
+ * by mtime, so the caller attributes only commits. `ownIds` are the episode's
332
+ * own tasks; any other task without a session key counts as foreign. */
333
+ export function sessionsOverlap(root, sessionKey, from, to, ownIds = []) {
276
334
  return taskDb(root, db => {
277
- const row = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.session_key') = ? ORDER BY rowid DESC LIMIT 1").get(...scopePair(root), sessionKey);
278
- return row ? TaskSchema.parse(JSON.parse(row.body)) : null;
335
+ const rows = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.started_at') <= ? AND (json_extract(body, '$.finished_at') IS NULL OR json_extract(body, '$.finished_at') >= ?)").all(...scopePair(root), to ?? new Date().toISOString(), from);
336
+ return rows.some(r => {
337
+ const task = TaskSchema.parse(JSON.parse(r.body));
338
+ if (ownIds.includes(task.task_id))
339
+ return false;
340
+ return !sessionKey || !task.session_key || task.session_key !== sessionKey;
341
+ });
279
342
  });
280
343
  }
281
344
  /** Every task of an episode, oldest first: the head and the prompts that continued it. */
@@ -298,36 +361,47 @@ function appendEvent(root, taskId, kind, body, eventId) {
298
361
  throw new Error("report event identity conflicts with existing evidence");
299
362
  return id;
300
363
  }
364
+ // The task the observation lands on: the named one, unless the host closed
365
+ // it and the session has since moved on.
366
+ let target = task;
301
367
  if (task.state !== "open" && !(kind === "check" && task.state === "interrupted")) {
302
368
  if (task.closed_by !== "host")
303
369
  throw new Error("task is already closed; start a new task for new work");
304
- // The host closed this task at Stop, but the turn went on (another hook's
305
- // block, a resumed prompt). Reopen it for the new observation; the next
306
- // Stop closes it again and the graph record is refreshed from the report.
307
- db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(TaskSchema.parse({ ...task, state: "open", finished_at: null, closed_by: undefined })), taskId);
370
+ // The host closed this task at Stop. When a later prompt of the session
371
+ // exists, this is that prompt's work named by an old id (the grounding
372
+ // says to reuse ids): it lands on the session's newest task, which the
373
+ // next Stop closes, instead of reopening one no Stop would close again.
374
+ // Verification stays on its own task (a result must match its start), and
375
+ // a task the agent closed is final. Otherwise the turn went on (another
376
+ // hook's block, a resumed prompt): reopen; the next Stop closes it again.
377
+ const newest = kind === "check" || kind === "check-start" || !task.session_key ? null : newestSessionTask(db, root, task.session_key);
378
+ if (newest && newest.task_id !== task.task_id && (newest.state === "open" || newest.closed_by === "host"))
379
+ target = newest;
380
+ if (target.state !== "open") {
381
+ target = TaskSchema.parse({ ...target, state: "open", finished_at: null, closed_by: undefined });
382
+ db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(target), target.task_id);
383
+ }
308
384
  }
385
+ const tid = target.task_id;
309
386
  if (kind === "check") {
310
387
  const check = ReportCheckSchema.parse(body);
311
- if (!check.check_id || !db.prepare("SELECT event_id FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'check-start'").get(check.check_id, taskId))
388
+ if (!check.check_id || !db.prepare("SELECT event_id FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'check-start'").get(check.check_id, tid))
312
389
  throw new Error("verification result has no matching start in this task");
313
390
  }
314
- const { total, bytes, pending } = db.prepare(`SELECT COUNT(*) AS total,
315
- COALESCE(SUM(length(CAST(body AS BLOB))), 0) AS bytes,
316
- SUM(CASE WHEN kind = 'check-start' AND (julianday('now') - julianday(at)) * 86400000 < COALESCE(json_extract(body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending
317
- FROM report_events WHERE task_id = ?`).get(taskId);
318
- const reserved = Math.max(0, (pending ?? 0) + (kind === "check-start" ? 1 : kind === "check" ? -1 : 0));
391
+ const { total, bytes } = db.prepare("SELECT COUNT(*) AS total, COALESCE(SUM(length(CAST(body AS BLOB))), 0) AS bytes FROM report_events WHERE task_id = ?").get(tid);
392
+ const reserved = Math.max(0, pendingChecks(db, tid) + (kind === "check-start" ? 1 : kind === "check" ? -1 : 0));
319
393
  if (total + 1 + reserved > MAX_EVENTS || bytes + Buffer.byteLength(encoded) + reserved * MAX_EVENT_BYTES > MAX_TASK_BYTES)
320
394
  throw new Error("task observation limit reached; start a new task");
321
395
  if (kind === "claim") {
322
396
  const claim = ReportClaimSchema.parse(body);
323
- const row = db.prepare("SELECT body FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'delivery'").get(claim.occurrence_id, taskId);
397
+ const row = db.prepare("SELECT body FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'delivery'").get(claim.occurrence_id, tid);
324
398
  if (!row)
325
399
  throw new Error("claim does not refer to a delivery in this task");
326
400
  const delivery = JSON.parse(row.body);
327
401
  if (!delivery.records.some(r => r.record_id === claim.record_id && r.content_hash === claim.content_hash))
328
402
  throw new Error("claim record revision was not delivered in this task");
329
403
  }
330
- const inserted = db.prepare("INSERT INTO report_events VALUES (?, ?, ?, ?, ?, ?)").run(id, taskId, kind, new Date().toISOString(), encoded, contentHash);
404
+ const inserted = db.prepare("INSERT INTO report_events VALUES (?, ?, ?, ?, ?, ?)").run(id, tid, kind, new Date().toISOString(), encoded, contentHash);
331
405
  indexDeliveryRecords(db, id, kind, body);
332
406
  // Advance only over contiguous observed inserts; an older writer may have
333
407
  // left unindexed events. Historical gaps are filled by bounded reads above.
@@ -17,7 +17,10 @@ export declare function reportSourceSnapshot(root: string): ReportSnapshot;
17
17
  * predicate's subject lives in a changed file. Everything else stays
18
18
  * `not-exercised` or `unavailable` — never "satisfied" by file overlap. */
19
19
  export declare function runReportConformance(root: string, store: HunchStore, taskId: string): ReportConformance[];
20
- export declare const DEFAULT_CHECK_TIMEOUT_MS = 120000;
20
+ /** A full suite is the usual check; two minutes turned passing suites into
21
+ * recorded timeouts (#268). The bound is a safety net for an abandoned runner,
22
+ * not a verdict. */
23
+ export declare const DEFAULT_CHECK_TIMEOUT_MS: number;
21
24
  export declare const MAX_CHECK_TIMEOUT_MS: number;
22
25
  /** A deliberately explicit command wrapper. The caller chooses the command;
23
26
  * reports never execute commands automatically to validate submitted claims. */
@@ -192,11 +192,14 @@ export function runReportConformance(root, store, taskId) {
192
192
  return value;
193
193
  });
194
194
  }
195
- export const DEFAULT_CHECK_TIMEOUT_MS = 120_000;
195
+ /** A full suite is the usual check; two minutes turned passing suites into
196
+ * recorded timeouts (#268). The bound is a safety net for an abandoned runner,
197
+ * not a verdict. */
198
+ export const DEFAULT_CHECK_TIMEOUT_MS = 15 * 60_000;
196
199
  export const MAX_CHECK_TIMEOUT_MS = 6 * 60 * 60_000;
197
200
  /** A deliberately explicit command wrapper. The caller chooses the command;
198
201
  * reports never execute commands automatically to validate submitted claims. */
199
- export async function runReportCheck(root, taskId, command, label, timeoutMs = 120_000, options = {}) {
202
+ export async function runReportCheck(root, taskId, command, label, timeoutMs = DEFAULT_CHECK_TIMEOUT_MS, options = {}) {
200
203
  // A full suite can legitimately run for half an hour (fnd_70dd5c4034); the
201
204
  // bound exists so an abandoned runner cannot hold a task open indefinitely.
202
205
  if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_CHECK_TIMEOUT_MS)
@@ -21,6 +21,18 @@ export declare function hookReportTaskId(root: string, provider: HookProvider, e
21
21
  * No raw prompt, host session identifier, or transcript is retained; a repository
22
22
  * that opts in (`taskTitles: "prompt"`) keeps only a bounded first-line title. */
23
23
  export declare function startHookReport(root: string, provider: HookProvider, event: HunchHookInput): string | null;
24
+ /** A prompt the host generated to report a background command's completion,
25
+ * not something the user typed. */
26
+ export declare function isNotificationPrompt(prompt: string | undefined): boolean;
27
+ /** Close, as host closes, the tasks of this session that an earlier prompt left
28
+ * open: the prompt was interrupted before its Stop, or a late observation
29
+ * reopened its task. Called when a new prompt starts (`keepNewest`: the new
30
+ * task, or the task a notification turn continues, stays open) and when the
31
+ * current prompt stops. Returns the ids closed here for the caller to persist. */
32
+ export declare function settleHookSession(root: string, provider: HookProvider, event: HunchHookInput, options?: {
33
+ keepId?: string | null;
34
+ keepNewest?: boolean;
35
+ }): string[];
24
36
  /** Stop ends the turn, so the prompt's task closes here as a HOST close: the
25
37
  * ledger says the task completed even when the agent never called finish, and
26
38
  * a task with observations becomes a graph record without anyone's cooperation.
@@ -28,9 +40,10 @@ export declare function startHookReport(root: string, provider: HookProvider, ev
28
40
  * continuation: the next observation reopens the task and the following Stop
29
41
  * closes it again (the record is refreshed from the report). An explicit agent
30
42
  * finish with any outcome overrides a host close. Pending verification keeps
31
- * the task open. Returns the task id when the task is closed after this call,
32
- * so the caller can persist its record; null when nothing is closed. */
33
- export declare function closeHookTask(root: string, provider: HookProvider, event: HunchHookInput): string | null;
43
+ * the task open. Tasks an earlier prompt of the session left open close here
44
+ * too. Returns the ids of the tasks closed after this call, so the caller can
45
+ * persist their records; empty when nothing is closed. */
46
+ export declare function closeHookTask(root: string, provider: HookProvider, event: HunchHookInput): string[];
34
47
  /** A presentation notice never denies Stop or injects another model turn.
35
48
  * A prompt with no observation at all prints nothing: the empty task row stays
36
49
  * in the ledger (hunch task list, the VS Code Contribution view) so "never
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { findRoot } from "./paths.js";
6
6
  import { canonicalReportRoot } from "./taskReportPaths.js";
7
7
  import { isCredentialFreeText } from "./types.js";
8
- import { continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, startReportTask } from "./taskReport.js";
8
+ import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
9
9
  import { reportSourceSnapshot } from "./taskReportEvidence.js";
10
10
  import { renderTaskReport } from "./taskReportRender.js";
11
11
  /** The exact task identity a native host prompt maps to. */
@@ -73,7 +73,14 @@ function identity(root, provider, event) {
73
73
  return null;
74
74
  if (!event.prompt_id)
75
75
  return "legacy";
76
- 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 {
@@ -103,11 +110,19 @@ export function startHookReport(root, provider, event) {
103
110
  // the episode's graph record is written under the first task's id. The key
104
111
  // is a hash; the host session identifier itself is still never retained.
105
112
  let links = {};
106
- if (event.session_id) {
107
- const sessionKey = reportHash([cwd, provider, event.session_id, event.agent_id ?? null]);
113
+ const sessionKey = hookSessionKey(cwd, provider, event);
114
+ if (sessionKey) {
108
115
  links = { session_key: sessionKey };
109
116
  try {
110
117
  const previous = latestSessionTask(root, sessionKey);
118
+ // A host notification (a background command finished) is not new work:
119
+ // it continues the session's latest task instead of opening an empty row,
120
+ // unless the agent already closed that task for good. The alias makes
121
+ // this prompt's Stop and hook observations report to that task.
122
+ if (previous && previous.task_id !== id && isNotificationPrompt(event.prompt) && previous.closed_by !== "agent") {
123
+ aliasReportTask(root, id, previous.task_id);
124
+ return taskInstruction(previous, cwdLiteral);
125
+ }
111
126
  const continued = previous && previous.task_id !== id ? continuationLinks(previous) : null;
112
127
  if (continued)
113
128
  links = { ...links, ...continued };
@@ -127,8 +142,41 @@ export function startHookReport(root, provider, event) {
127
142
  throw error;
128
143
  task = existing;
129
144
  }
145
+ return taskInstruction(task, cwdLiteral);
146
+ }
147
+ function taskInstruction(task, cwdLiteral) {
130
148
  return `Hunch has opened this prompt's report: ${task.task_id}. Reuse this exact ID for this prompt. Call hunch_task(action: "start", task_id: "${task.task_id}", title: ${JSON.stringify(task.title)}, cwd: ${cwdLiteral}) to obtain verification_argv; do not create another report. Pass this task_id and cwd: ${cwdLiteral} to hunch_context and decision/correction/finding captures, and pass the same cwd when finishing with hunch_task before responding. A host Stop notice will show the evidence even if no task-linked memory was observed.`;
131
149
  }
150
+ /** The session key a hook event maps to: a hash of (root, provider, session,
151
+ * agent), never the identifier itself. Null without a host session. */
152
+ function hookSessionKey(cwd, provider, event) {
153
+ return event.session_id ? reportHash([cwd, provider, event.session_id, event.agent_id ?? null]) : null;
154
+ }
155
+ /** A prompt the host generated to report a background command's completion,
156
+ * not something the user typed. */
157
+ export function isNotificationPrompt(prompt) {
158
+ if (typeof prompt !== "string")
159
+ return false;
160
+ const firstLine = prompt.split(/\r?\n/).map(l => l.trim()).find(l => l.length > 0) ?? "";
161
+ return /^<task-notification>/i.test(firstLine);
162
+ }
163
+ /** Close, as host closes, the tasks of this session that an earlier prompt left
164
+ * open: the prompt was interrupted before its Stop, or a late observation
165
+ * reopened its task. Called when a new prompt starts (`keepNewest`: the new
166
+ * task, or the task a notification turn continues, stays open) and when the
167
+ * current prompt stops. Returns the ids closed here for the caller to persist. */
168
+ export function settleHookSession(root, provider, event, options = {}) {
169
+ const cwd = nativeHookCwd(root, provider, event);
170
+ const key = cwd ? hookSessionKey(cwd, provider, event) : null;
171
+ if (!key)
172
+ return [];
173
+ try {
174
+ return settleSessionTasks(root, key, options);
175
+ }
176
+ catch {
177
+ return [];
178
+ }
179
+ }
132
180
  /** Stop ends the turn, so the prompt's task closes here as a HOST close: the
133
181
  * ledger says the task completed even when the agent never called finish, and
134
182
  * a task with observations becomes a graph record without anyone's cooperation.
@@ -136,30 +184,34 @@ export function startHookReport(root, provider, event) {
136
184
  * continuation: the next observation reopens the task and the following Stop
137
185
  * closes it again (the record is refreshed from the report). An explicit agent
138
186
  * finish with any outcome overrides a host close. Pending verification keeps
139
- * the task open. 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. */
187
+ * the task open. Tasks an earlier prompt of the session left open close here
188
+ * too. Returns the ids of the tasks closed after this call, so the caller can
189
+ * persist their records; empty when nothing is closed. */
141
190
  export function closeHookTask(root, provider, event) {
142
191
  let id;
143
192
  try {
144
193
  id = identity(root, provider, event);
145
194
  }
146
195
  catch {
147
- return null;
196
+ return [];
148
197
  }
149
198
  if (!id || id === "legacy")
150
- return null;
199
+ return [];
200
+ const closed = [];
151
201
  try {
152
202
  const task = readTaskReport(root, id).task;
153
- if (task.state === "interrupted")
154
- return null;
155
203
  if (task.state === "open")
156
204
  finishReportTask(root, id, "completed", { by: "host" });
157
- return id;
205
+ if (task.state !== "interrupted")
206
+ closed.push(id);
158
207
  }
159
208
  catch {
160
- // No task for this prompt, or verification still running: leave it as it is.
161
- return null;
209
+ // No task for this prompt (a notification turn), or verification still running: leave it as it is.
162
210
  }
211
+ for (const other of settleHookSession(root, provider, event, { keepId: id }))
212
+ if (!closed.includes(other))
213
+ closed.push(other);
214
+ return closed;
163
215
  }
164
216
  /** A presentation notice never denies Stop or injects another model turn.
165
217
  * A prompt with no observation at all prints nothing: the empty task row stays
@@ -1,5 +1,9 @@
1
+ /** Files Hunch regenerates on every capture (src/integrations/providers.ts,
2
+ * claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
3
+ export declare const HUNCH_MANAGED_FILES: ReadonlySet<string>;
1
4
  export declare function gitTouchedFiles(root: string, startedAt: string, finishedAt: string | null, options?: {
2
5
  limit?: number;
3
6
  timeoutMs?: number;
4
7
  now?: number;
8
+ workingTree?: boolean;
5
9
  }): string[];
@@ -8,15 +8,26 @@
8
8
  * never a failed finish):
9
9
  * - commits authored by the configured git user whose commit time falls in
10
10
  * the task window (merges excluded);
11
- * - working-tree changes (modified, added, untracked) whose mtime falls in it.
12
- * 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. */
11
+ * - working-tree changes (modified, added, untracked) whose mtime falls in it,
12
+ * unless the caller knows another session shared the checkout (`workingTree:
13
+ * false`): mtimes cannot say whose edit it was, commits can.
14
+ * Hunch's own work never counts as the task's: memory and cache paths, commits
15
+ * Hunch makes (`hunch:` subjects captures, task records, repairs), and the
16
+ * grounding files a capture rewrites (CLAUDE.md, AGENTS.md, the host rule
17
+ * files) when they merely changed in the working tree; a user commit that
18
+ * edits one of those files still counts, as does a delivery that named it.
19
+ * Deleted paths are skipped: nothing dates the deletion. Commit dates get one
20
+ * second of slack (git keeps seconds); working-tree mtimes get none before
21
+ * the start. */
16
22
  import { execFileSync } from "node:child_process";
17
23
  import { statSync } from "node:fs";
18
24
  import { join } from "node:path";
19
25
  const EXCLUDED_SEGMENTS = new Set([".hunch", ".hunch-cache", ".git"]);
26
+ /** Files Hunch regenerates on every capture (src/integrations/providers.ts,
27
+ * claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
28
+ export const HUNCH_MANAGED_FILES = new Set(["CLAUDE.md", "AGENTS.md", ".cursor/rules/hunch.mdc", ".github/copilot-instructions.md", ".windsurf/rules/hunch.md"]);
29
+ /** Commit subjects Hunch writes itself. */
30
+ const HUNCH_COMMIT_SUBJECT = /^hunch:/i;
20
31
  function gitDate(ms) {
21
32
  // Second resolution, a format every git accepts.
22
33
  return `${new Date(ms).toISOString().slice(0, 19).replace("T", " ")} +0000`;
@@ -38,8 +49,9 @@ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
38
49
  delete env[key];
39
50
  const run = (args) => execFileSync("git", ["-C", root, "-c", "core.quotePath=false", ...args], { env, encoding: "utf8", timeout, maxBuffer: 4_000_000, stdio: ["ignore", "pipe", "ignore"] });
40
51
  const out = new Set();
52
+ const normalize = (raw) => raw.trim().replace(/\\/g, "/").replace(/^\.\//, "");
41
53
  const keep = (raw) => {
42
- const path = raw.trim().replace(/\\/g, "/").replace(/^\.\//, "");
54
+ const path = normalize(raw);
43
55
  if (!path || path.split("/").some((segment) => EXCLUDED_SEGMENTS.has(segment)))
44
56
  return;
45
57
  out.add(path);
@@ -47,12 +59,20 @@ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
47
59
  try {
48
60
  const email = run(["config", "--get", "user.email"]).trim();
49
61
  if (email) {
50
- 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);
62
+ // One record per commit: a separator, the subject, then the paths.
63
+ const log = run(["log", "--no-merges", "-n", "50", `--since=${gitDate(from)}`, `--until=${gitDate(to)}`, `--author=${email}`, "--format=%x1e%s", "--name-only"]);
64
+ for (const block of log.split("\x1e")) {
65
+ const [subject = "", ...paths] = block.split("\n");
66
+ if (HUNCH_COMMIT_SUBJECT.test(subject.trim()))
67
+ continue;
68
+ for (const line of paths)
69
+ keep(line);
70
+ }
53
71
  }
54
72
  }
55
73
  catch { /* no commits, no git user, or no git: the working tree may still say something */ }
74
+ if (options.workingTree === false)
75
+ return [...out].sort().slice(0, limit);
56
76
  try {
57
77
  const entries = run(["status", "--porcelain=v1", "-z", "--untracked-files=all"]).split("\0").filter(Boolean);
58
78
  for (let i = 0; i < entries.length; i++) {
@@ -61,7 +81,7 @@ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
61
81
  // A rename or copy is followed by its original path as a separate entry.
62
82
  if (code[0] === "R" || code[0] === "C")
63
83
  i++;
64
- if (code.includes("D") || !path)
84
+ if (code.includes("D") || !path || HUNCH_MANAGED_FILES.has(normalize(path)))
65
85
  continue;
66
86
  try {
67
87
  const mtime = statSync(join(root, path)).mtimeMs;
@@ -193,4 +193,13 @@ export declare function boundedTaskReportForHost(report: ReturnType<typeof readT
193
193
  };
194
194
  full_report: string;
195
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
+ };
196
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.38.0",
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.38.0",
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.38.0",
16
+ "version": "1.38.1",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {