@davesheffer/hunch 1.39.2 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/cli/index.js +154 -64
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/constitution/g2BehaviorCandidates.js +6 -1
  5. package/dist/constitution/g2Candidates.js +11 -1
  6. package/dist/constitution/structural.js +10 -0
  7. package/dist/core/correction.js +12 -7
  8. package/dist/core/delivery.d.ts +34 -0
  9. package/dist/core/delivery.js +60 -1
  10. package/dist/core/docanchors.js +195 -11
  11. package/dist/core/format.js +6 -1
  12. package/dist/core/glob.d.ts +25 -0
  13. package/dist/core/glob.js +43 -0
  14. package/dist/core/hookcache.d.ts +9 -2
  15. package/dist/core/hookcache.js +10 -3
  16. package/dist/core/paths.d.ts +48 -5
  17. package/dist/core/paths.js +89 -6
  18. package/dist/core/procstart.d.ts +94 -0
  19. package/dist/core/procstart.js +244 -0
  20. package/dist/core/served.d.ts +6 -3
  21. package/dist/core/served.js +10 -6
  22. package/dist/core/taskDelivery.js +27 -1
  23. package/dist/core/taskReport.d.ts +2 -0
  24. package/dist/core/taskReport.js +46 -11
  25. package/dist/core/taskReportEvidence.js +17 -1
  26. package/dist/core/taskReportHook.d.ts +19 -0
  27. package/dist/core/taskReportHook.js +54 -6
  28. package/dist/core/verifyLauncher.d.ts +21 -0
  29. package/dist/core/verifyLauncher.js +37 -0
  30. package/dist/core/workspace.d.ts +9 -0
  31. package/dist/core/workspace.js +15 -0
  32. package/dist/extractors/git.d.ts +5 -0
  33. package/dist/extractors/git.js +87 -10
  34. package/dist/extractors/indexer.d.ts +10 -0
  35. package/dist/extractors/indexer.js +56 -10
  36. package/dist/extractors/k8sManifest.d.ts +13 -0
  37. package/dist/extractors/k8sManifest.js +207 -23
  38. package/dist/extractors/landscapeDiscovery.js +9 -1
  39. package/dist/extractors/nativeTreeSitter.d.ts +24 -0
  40. package/dist/extractors/nativeTreeSitter.js +54 -1
  41. package/dist/extractors/parse.js +6 -1
  42. package/dist/integrations/claudemd.js +3 -3
  43. package/dist/integrations/hookmatch.d.ts +25 -0
  44. package/dist/integrations/hookmatch.js +58 -0
  45. package/dist/integrations/hooks.d.ts +79 -9
  46. package/dist/integrations/hooks.js +559 -46
  47. package/dist/integrations/providers.js +2 -17
  48. package/dist/integrations/scaffold.d.ts +1 -1
  49. package/dist/integrations/scaffold.js +24 -21
  50. package/dist/integrations/workspaceLedger.d.ts +10 -0
  51. package/dist/integrations/workspaceLedger.js +9 -1
  52. package/dist/mcp/server.d.ts +10 -0
  53. package/dist/mcp/server.js +32 -16
  54. package/dist/mcp/taskReportTools.d.ts +5 -9
  55. package/dist/mcp/taskReportTools.js +9 -20
  56. package/dist/serve/writelock.d.ts +21 -1
  57. package/dist/serve/writelock.js +157 -16
  58. package/dist/store/changeLedger.d.ts +9 -3
  59. package/dist/store/changeLedger.js +36 -10
  60. package/dist/store/hunchStore.d.ts +42 -2
  61. package/dist/store/hunchStore.js +105 -17
  62. package/dist/store/jsonStore.d.ts +6 -4
  63. package/dist/store/jsonStore.js +225 -32
  64. package/dist/store/merge.d.ts +7 -4
  65. package/dist/store/merge.js +65 -5
  66. package/dist/store/privateMigrate.js +10 -0
  67. package/dist/wiki/adopt.js +13 -2
  68. package/package.json +1 -1
  69. package/server.json +2 -2
@@ -99,7 +99,10 @@ const TaskSchema = z.object({
99
99
  export const LessonReferenceSchema = z.object({
100
100
  kind: safeText(64), record_id: safeText(512), content_hash: hashSchema.optional(),
101
101
  }).strict();
102
- function taskDb(root, run) {
102
+ /** Explicit report operations wait out a competing writer; hook/delivery paths keep
103
+ * the ledger's 100 ms default so a receipt never costs a delivery. */
104
+ const PATIENT_BUSY_MS = 5_000;
105
+ function taskDb(root, run, patient = false) {
103
106
  return withServedDatabase(root, (db) => {
104
107
  db.exec(`CREATE TABLE IF NOT EXISTS report_tasks (
105
108
  task_id TEXT PRIMARY KEY, scope TEXT NOT NULL, body TEXT NOT NULL
@@ -115,10 +118,13 @@ function taskDb(root, run) {
115
118
  );
116
119
  CREATE INDEX IF NOT EXISTS report_record_lookup ON report_record_links(kind, record_id, content_hash);
117
120
  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);
119
121
  CREATE TABLE IF NOT EXISTS report_task_aliases (alias_id TEXT PRIMARY KEY, task_id TEXT NOT NULL);`);
122
+ // Seed only when the row is missing: an unconditional INSERT made every pure
123
+ // read take the writer lock. INSERT OR IGNORE still settles the race.
124
+ if (!db.prepare("SELECT 1 FROM report_history_progress WHERE id = 1").get())
125
+ db.exec("INSERT OR IGNORE INTO report_history_progress VALUES (1, 0)");
120
126
  return run(db);
121
- });
127
+ }, patient ? { busyTimeoutMs: PATIENT_BUSY_MS } : {});
122
128
  }
123
129
  /** A prompt identity that reports to another prompt's task: a host notification
124
130
  * turn continues the session's latest task instead of opening a row. Explicit,
@@ -134,6 +140,11 @@ export function aliasReportTask(root, aliasId, taskId) {
134
140
  export function resolveReportTask(root, id) {
135
141
  return taskDb(root, db => db.prepare("SELECT task_id FROM report_task_aliases WHERE alias_id = ?").get(id)?.task_id ?? id);
136
142
  }
143
+ /** Whether a task id names a real task of THIS repository/worktree. */
144
+ export function reportTaskExists(root, id) {
145
+ TaskIdSchema.parse(id);
146
+ return taskDb(root, db => !!db.prepare("SELECT 1 FROM report_tasks WHERE task_id = ? AND scope IN (?, ?, ?)").get(id, ...scopePair(root)));
147
+ }
137
148
  function deliveryRecords(kind, body) {
138
149
  if (kind === "save")
139
150
  return [ReportSaveSchema.parse(body).record];
@@ -298,10 +309,12 @@ function newestSessionTask(db, root, sessionKey) {
298
309
  export function latestSessionTask(root, sessionKey) {
299
310
  return taskDb(root, db => newestSessionTask(db, root, sessionKey));
300
311
  }
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. */
312
+ /** Check-starts still inside their own timeout plus the grace window whose own
313
+ * result has not arrived: while positive, a runner may still deliver a result.
314
+ * A result clears the start it names (its `check_id`) and nothing else — netting
315
+ * results against starts let a finished old check hide a running new one. */
303
316
  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);
317
+ const { pending } = db.prepare(`SELECT COUNT(*) AS pending FROM report_events s WHERE s.task_id = ? AND s.kind = 'check-start' AND (julianday('now') - julianday(s.at)) * 86400000 < COALESCE(json_extract(s.body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} AND NOT EXISTS (SELECT 1 FROM report_events c WHERE c.task_id = s.task_id AND c.kind = 'check' AND json_extract(c.body, '$.check_id') = s.event_id)`).get(taskId);
305
318
  return pending ?? 0;
306
319
  }
307
320
  /** Tasks of a session that an earlier prompt left open are over once the
@@ -353,6 +366,9 @@ function appendEvent(root, taskId, kind, body, eventId) {
353
366
  if (!/^(hev|hocc)_[a-f0-9]{24}$/.test(id))
354
367
  throw new Error("invalid report event identity");
355
368
  const contentHash = reportHash(body);
369
+ // Explicit CLI/agent operations; passive observers (delivery, save, refusal,
370
+ // conformance) keep the default so an observation never costs a delivery.
371
+ const patient = kind === "check-start" || kind === "check" || kind === "claim";
356
372
  return taskDb(root, db => transaction(db, () => {
357
373
  const task = readTask(db, root, taskId);
358
374
  const prior = db.prepare("SELECT task_id, kind, content_hash FROM report_events WHERE event_id = ?").get(id);
@@ -408,7 +424,7 @@ function appendEvent(root, taskId, kind, body, eventId) {
408
424
  const seq = Number(inserted.lastInsertRowid);
409
425
  db.prepare("UPDATE report_history_progress SET through_rowid = ? WHERE id = 1 AND through_rowid = ?").run(seq, seq - 1);
410
426
  return id;
411
- }));
427
+ }), patient);
412
428
  }
413
429
  /** The record revisions among `records` that this task has not received before.
414
430
  * Powers the one-line "Hunch recalled …" indication on a lesson's FIRST delivery
@@ -472,12 +488,32 @@ export function recordReportConformance(root, taskId, conformance) {
472
488
  throw new Error("rule evaluation does not name a record revision delivered in this task");
473
489
  return appendEvent(root, taskId, "conformance", value);
474
490
  }
491
+ /** SQLITE_BUSY (5) / SQLITE_LOCKED (6); node:sqlite carries the extended code. */
492
+ function isBusyError(error) {
493
+ const code = error.errcode;
494
+ if (typeof code === "number")
495
+ return (code & 0xff) === 5 || (code & 0xff) === 6;
496
+ return /database is locked/i.test(error?.message ?? "");
497
+ }
475
498
  /** Only the local runner calls this. MCP never accepts a claimed successful check. */
476
499
  export function recordReportCheck(root, taskId, check) {
477
500
  const value = ReportCheckSchema.parse(check);
478
501
  if (!value.check_id)
479
502
  throw new Error("verification result requires a reserved check identity");
480
- return appendEvent(root, taskId, "check", value, `hev_${reportHash({ taskId, check: value.check_id }).slice(7, 31)}`);
503
+ const id = `hev_${reportHash({ taskId, check: value.check_id }).slice(7, 31)}`;
504
+ // A result that took minutes to produce must not be lost to a busy ledger. The
505
+ // write is idempotent (deterministic id; appendEvent returns the existing id for
506
+ // identical content), so a retry is safe. Each attempt already waits out
507
+ // PATIENT_BUSY_MS in BEGIN IMMEDIATE; no sleep between them.
508
+ for (let attempt = 1;; attempt++) {
509
+ try {
510
+ return appendEvent(root, taskId, "check", value, id);
511
+ }
512
+ catch (error) {
513
+ if (attempt >= 3 || !isBusyError(error))
514
+ throw error;
515
+ }
516
+ }
481
517
  }
482
518
  /** A start without a result blocks completion only while the runner could still
483
519
  * deliver one: its own timeout plus a minute of grace. After that the runner is
@@ -509,14 +545,13 @@ export function finishReportTask(root, taskId, state = "completed", options = {}
509
545
  return task;
510
546
  }
511
547
  if (state === "completed") {
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);
513
- if ((pending ?? 0) > 0)
548
+ if (pendingChecks(db, taskId) > 0)
514
549
  throw new Error("verification is still running or was interrupted; wait for its result or close the task as interrupted");
515
550
  }
516
551
  const finished = TaskSchema.parse({ ...task, state, finished_at: new Date().toISOString(), closed_by: by });
517
552
  db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(finished), taskId);
518
553
  return finished;
519
- }));
554
+ }), by === "agent");
520
555
  }
521
556
  /** A report with no observation of any kind. Presentation surfaces may stay
522
557
  * silent for it; the task row itself is retained so "never touched Hunch" is
@@ -197,6 +197,8 @@ export function runReportConformance(root, store, taskId) {
197
197
  * not a verdict. */
198
198
  export const DEFAULT_CHECK_TIMEOUT_MS = 15 * 60_000;
199
199
  export const MAX_CHECK_TIMEOUT_MS = 6 * 60 * 60_000;
200
+ /** After the command itself exits, everything it wrote is already in the pipe; the window only lets those buffered chunks be read before settling. */
201
+ const EXIT_DRAIN_MS = 500;
200
202
  /** A deliberately explicit command wrapper. The caller chooses the command;
201
203
  * reports never execute commands automatically to validate submitted claims. */
202
204
  export async function runReportCheck(root, taskId, command, label, timeoutMs = DEFAULT_CHECK_TIMEOUT_MS, options = {}) {
@@ -242,7 +244,7 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
242
244
  }
243
245
  const child = started;
244
246
  let timedOut = false, cancelled = false, settled = false;
245
- let cleanupTimer;
247
+ let cleanupTimer, drainTimer;
246
248
  const settle = (code) => {
247
249
  if (settled)
248
250
  return;
@@ -251,6 +253,8 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
251
253
  options.signal?.removeEventListener("abort", cancel);
252
254
  if (cleanupTimer)
253
255
  clearTimeout(cleanupTimer);
256
+ if (drainTimer)
257
+ clearTimeout(drainTimer);
254
258
  child.stdout.destroy();
255
259
  child.stderr.destroy();
256
260
  resolveResult({ code, timedOut, cancelled, hash: reportHash({ stdout: stdout.digest("hex"), stderr: stderr.digest("hex") }) });
@@ -296,6 +300,18 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
296
300
  }
297
301
  settle(null);
298
302
  });
303
+ // `close` waits for EVERY holder of the stdio pipes; a command that leaves a
304
+ // helper inheriting stdout (dev server, watcher) and exits 0 would otherwise
305
+ // burn the whole budget and be recorded as a timeout (#304). The command's own
306
+ // `exit` is the result; settle on it after a short drain of the buffered output.
307
+ child.once("exit", code => {
308
+ if (settled)
309
+ return;
310
+ // The command finished inside its budget; the drain window is not part of it.
311
+ if (code !== null && !timedOut)
312
+ clearTimeout(timer);
313
+ drainTimer = setTimeout(() => settle(code), EXIT_DRAIN_MS);
314
+ });
299
315
  child.once("close", code => settle(code));
300
316
  options.signal?.addEventListener("abort", cancel, { once: true });
301
317
  if (options.signal?.aborted)
@@ -21,6 +21,25 @@ 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
+ /** The hook already opened the task, so the model needs no start call: the only
25
+ * thing start used to supply was verification_argv, and the launcher is printed
26
+ * inline here. Identical in substance for EVERY hook provider that reaches this
27
+ * function; the one variation is capability-driven, never host-named — where the
28
+ * host is not PROVEN to close the task it opened (HOST_CLOSES_TASK) nobody but the
29
+ * next prompt's settle would close it, so finish stays mandatory there. Elsewhere
30
+ * finish is CONDITIONAL: ~87 start/finish round trips a day mostly returned "No
31
+ * task-linked delivery observed", and the host's Stop hook closes the task and
32
+ * shows the evidence either way. FAILS OPEN (con_03a0b94b2e): if the launcher
33
+ * cannot be computed, fall back to asking for the start call — that path is then
34
+ * the only source of both the launcher and the finish instruction, so it carries
35
+ * its own finish sentence. */
36
+ export declare function taskInstruction(task: {
37
+ task_id: string;
38
+ title: string;
39
+ }, cwdLiteral: string, provider: HookProvider, launcher?: () => {
40
+ shell: string;
41
+ note?: string;
42
+ }): string;
24
43
  /** A prompt the host generated to report a background command's completion,
25
44
  * not something the user typed. */
26
45
  export declare function isNotificationPrompt(prompt: string | undefined): boolean;
@@ -5,9 +5,10 @@ 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 { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
8
+ import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, reportTaskExists, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
9
9
  import { reportSourceSnapshot } from "./taskReportEvidence.js";
10
10
  import { renderTaskReport } from "./taskReportRender.js";
11
+ import { verificationLauncher } from "./verifyLauncher.js";
11
12
  /** The exact task identity a native host prompt maps to. */
12
13
  export function promptTaskId(root, sessionId, promptId, agentId = null, provider = "claude") {
13
14
  return `htask_${reportHash([canonicalReportRoot(root), provider, sessionId, promptId, agentId]).slice(7, 31)}`;
@@ -15,6 +16,16 @@ export function promptTaskId(root, sessionId, promptId, agentId = null, provider
15
16
  /** Hosts whose hooks deliver a native per-prompt identity (Claude Code's
16
17
  * prompt_id, Codex's turn_id). Others get no task from a hook. */
17
18
  const NATIVE_PROMPT_HOSTS = new Set(["claude", "codex"]);
19
+ /** Hosts PROVEN to close a task they opened, so its evidence is shown without
20
+ * the agent's cooperation and the finish call may be made conditional. A host
21
+ * belongs here only when BOTH hold: (1) `hunch init` wires its stop event, and
22
+ * (2) its stop payload carries the same native prompt identity the task was
23
+ * opened under, so `closeHookTask` actually resolves that task and closes it.
24
+ * (2) is what excludes a host with a stop hook but no native identity: outside
25
+ * NATIVE_PROMPT_HOSTS `nativeHookCwd` returns null on Stop, so nothing is
26
+ * closed and a skipped finish would leak an open task. Everywhere else finish
27
+ * stays mandatory. Adding a host requires proving both, never its name. */
28
+ const HOST_CLOSES_TASK = new Set(["claude", "codex"]);
18
29
  const NATIVE_TASK_TITLE = "Assistant task";
19
30
  const GENERIC_TASK_TITLES = new Set(["Assistant task", "Claude task"]);
20
31
  const TASK_TITLE_MAX = 72;
@@ -85,7 +96,19 @@ function identity(root, provider, event) {
85
96
  export function hookReportTaskId(root, provider, event) {
86
97
  try {
87
98
  const id = identity(root, provider, event);
88
- return id === "legacy" ? null : id;
99
+ if (!id || id === "legacy")
100
+ return null;
101
+ // A subagent's tool call carries the prompt's session and prompt identity
102
+ // plus its own agent_id, and no prompt ever opens a task under that
103
+ // identity (subagents never fire UserPromptSubmit): its deliveries and
104
+ // denials belong to the prompt's task. An agent-scoped task that DOES
105
+ // exist keeps its own evidence, and without a prompt task nothing is guessed.
106
+ if (event.agent_id && event.session_id && event.prompt_id && !reportTaskExists(root, id)) {
107
+ const parent = resolveReportTask(root, promptTaskId(root, event.session_id, event.prompt_id, null, provider));
108
+ if (reportTaskExists(root, parent))
109
+ return parent;
110
+ }
111
+ return id;
89
112
  }
90
113
  catch {
91
114
  return null;
@@ -121,7 +144,7 @@ export function startHookReport(root, provider, event) {
121
144
  // this prompt's Stop and hook observations report to that task.
122
145
  if (previous && previous.task_id !== id && isNotificationPrompt(event.prompt) && previous.closed_by !== "agent") {
123
146
  aliasReportTask(root, id, previous.task_id);
124
- return taskInstruction(previous, cwdLiteral);
147
+ return taskInstruction(previous, cwdLiteral, provider);
125
148
  }
126
149
  const continued = previous && previous.task_id !== id ? continuationLinks(previous) : null;
127
150
  if (continued)
@@ -142,10 +165,35 @@ export function startHookReport(root, provider, event) {
142
165
  throw error;
143
166
  task = existing;
144
167
  }
145
- return taskInstruction(task, cwdLiteral);
168
+ return taskInstruction(task, cwdLiteral, provider);
146
169
  }
147
- function taskInstruction(task, cwdLiteral) {
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.`;
170
+ /** The hook already opened the task, so the model needs no start call: the only
171
+ * thing start used to supply was verification_argv, and the launcher is printed
172
+ * inline here. Identical in substance for EVERY hook provider that reaches this
173
+ * function; the one variation is capability-driven, never host-named — where the
174
+ * host is not PROVEN to close the task it opened (HOST_CLOSES_TASK) nobody but the
175
+ * next prompt's settle would close it, so finish stays mandatory there. Elsewhere
176
+ * finish is CONDITIONAL: ~87 start/finish round trips a day mostly returned "No
177
+ * task-linked delivery observed", and the host's Stop hook closes the task and
178
+ * shows the evidence either way. FAILS OPEN (con_03a0b94b2e): if the launcher
179
+ * cannot be computed, fall back to asking for the start call — that path is then
180
+ * the only source of both the launcher and the finish instruction, so it carries
181
+ * its own finish sentence. */
182
+ export function taskInstruction(task, cwdLiteral, provider, launcher = verificationLauncher) {
183
+ const head = `Hunch has already opened this prompt's report: ${task.task_id}. Reuse this exact ID; never open another report. Pass this task_id and cwd: ${cwdLiteral} to hunch_context and decision/correction/finding captures.`;
184
+ let verify;
185
+ try {
186
+ const l = launcher();
187
+ verify = ` Never call hunch_task start for it. For checks, run: ${l.shell} task verify ${task.task_id} -- <command> [arguments]${l.note ?? ""}. Default budget 15 min; add --timeout <seconds> before -- for longer suites.`;
188
+ }
189
+ catch {
190
+ return `${head} Call hunch_task(action: "start", task_id: "${task.task_id}", title: ${JSON.stringify(task.title)}, cwd: ${cwdLiteral}) to obtain verification_argv, and finish with hunch_task(action: "finish", task_id, cwd) before responding and show its card.`;
191
+ }
192
+ const used = `this task used Hunch (a hunch_* call on this ID, a task verify check, Hunch hook context you acted on, or an application to claim)`;
193
+ const finish = HOST_CLOSES_TASK.has(provider)
194
+ ? ` ONLY if ${used}, call hunch_task(action: "finish", task_id, cwd) before responding and show its card; otherwise skip it — this host's stop hook closes the task and shows the evidence.`
195
+ : ` No stop hook closes this task, so finish it yourself whether or not ${used}: call hunch_task(action: "finish", task_id, cwd) before responding and show its card.`;
196
+ return `${head}${verify}${finish}`;
149
197
  }
150
198
  /** The session key a hook event maps to: a hash of (root, provider, session,
151
199
  * agent), never the identifier itself. Null without a host session. */
@@ -0,0 +1,21 @@
1
+ /** Structured argv is authoritative; the shell hint uses literal quoting, and
2
+ * `note` disambiguates it where one platform has two incompatible shells. */
3
+ export declare function verificationLauncher(): {
4
+ argv: string[];
5
+ shell: string;
6
+ note: string;
7
+ };
8
+ /** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
9
+ * loader; a published `.js` build needs nothing) and `resolve` is that
10
+ * module's `import.meta.resolve`. Callers in sibling directories (src/core,
11
+ * src/mcp) resolve the same `../cli/index.{ts|js}`, but each must pass ITS OWN
12
+ * import.meta so the dev/published discrimination stays honest. The loader is
13
+ * resolved ONLY on the source path: `import.meta.resolve` throws for a package
14
+ * that is not installed, and `tsx` is a devDependency absent from every
15
+ * published install (#261). `platform` defaults to the running one and exists so
16
+ * the Windows quoting branch is testable from any machine. */
17
+ export declare function verificationLauncherFor(metaUrl: string, resolve: (specifier: string) => string, platform?: NodeJS.Platform): {
18
+ argv: string[];
19
+ shell: string;
20
+ note: string;
21
+ };
@@ -0,0 +1,37 @@
1
+ /** The launcher that runs `hunch task verify` from THIS installation, not a
2
+ * potentially stale global binary. Core, not mcp: the prompt hook prints the
3
+ * command inline (so the model needs no hunch_task start call just to learn it)
4
+ * and a hook must never pull in the MCP SDK. */
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ /** Structured argv is authoritative; the shell hint uses literal quoting, and
7
+ * `note` disambiguates it where one platform has two incompatible shells. */
8
+ export function verificationLauncher() {
9
+ return verificationLauncherFor(import.meta.url, (specifier) => import.meta.resolve(specifier));
10
+ }
11
+ /** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
12
+ * loader; a published `.js` build needs nothing) and `resolve` is that
13
+ * module's `import.meta.resolve`. Callers in sibling directories (src/core,
14
+ * src/mcp) resolve the same `../cli/index.{ts|js}`, but each must pass ITS OWN
15
+ * import.meta so the dev/published discrimination stays honest. The loader is
16
+ * resolved ONLY on the source path: `import.meta.resolve` throws for a package
17
+ * that is not installed, and `tsx` is a devDependency absent from every
18
+ * published install (#261). `platform` defaults to the running one and exists so
19
+ * the Windows quoting branch is testable from any machine. */
20
+ export function verificationLauncherFor(metaUrl, resolve, platform = process.platform) {
21
+ const dev = metaUrl.endsWith(".ts");
22
+ const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, metaUrl));
23
+ // `--import` takes a URL. Converting the resolved loader to a path made Node on
24
+ // Windows reject it ("Received protocol 'c:'"), so every verification launched
25
+ // from a source checkout there failed before running and cards showed no check.
26
+ const loader = dev ? resolve("tsx") : null;
27
+ const argv = [process.execPath, ...(loader ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
28
+ const win = platform === "win32";
29
+ const quote = (s) => win ? `'${s.replace(/'/g, "''")}'` : `'${s.replace(/'/g, "'\\''")}'`;
30
+ // Windows hosts run either PowerShell or a POSIX shell (Git Bash), and the
31
+ // call operator that PowerShell needs is a syntax error in the other. The hint
32
+ // is quoted for PowerShell and says how to use it in the other; the structured
33
+ // argv stays the unambiguous form.
34
+ const note = win ? ` (PowerShell form; in a POSIX shell such as Git Bash drop the leading "& ")` : "";
35
+ return { argv, shell: `${win ? "& " : ""}${argv.map(quote).join(" ")}`, note };
36
+ }
37
+ //# sourceMappingURL=verifyLauncher.js.map
@@ -253,4 +253,13 @@ export interface PrunePlan {
253
253
  /** Why a branch must not be pruned, or null when it may. The rules are the documented ones:
254
254
  * proven merged, not the default branch, worktree (if any) clean, unlocked and present. */
255
255
  export declare function pruneRefusal(b: WorkspaceBranch, wt: WorkspaceWorktree | undefined): string | null;
256
+ /** The repo-relative path of a workspace record committed into the PUBLIC `.hunch/`. */
257
+ export declare function publicWorkspaceRecordPath(id: string): string;
258
+ /** The manual recipe for removing a workspace record that lives in the repo-tracked
259
+ * `.hunch/`. Hunch's own publication pump is deliberately ADDITIVE — it refuses to stage
260
+ * a tracked deletion so a stale clone can never erase team history (the same stance
261
+ * `compact --apply` takes) — so deleting such a record here would strand `D .hunch/…` in
262
+ * the working tree and wedge every later auto-commit. The removal is a normal, reviewable
263
+ * git change the human makes instead. */
264
+ export declare function publicWorkspaceRemovalRecipe(id: string, machine: string): string;
256
265
  export declare function planPrune(live: Workspace, others: readonly Workspace[]): PrunePlan;
@@ -346,6 +346,21 @@ export function pruneRefusal(b, wt) {
346
346
  return wt.dirty === null ? "worktree state could not be read" : "worktree has uncommitted or untracked changes";
347
347
  return null;
348
348
  }
349
+ /** The repo-relative path of a workspace record committed into the PUBLIC `.hunch/`. */
350
+ export function publicWorkspaceRecordPath(id) {
351
+ return `.hunch/workspaces/${id}.json`;
352
+ }
353
+ /** The manual recipe for removing a workspace record that lives in the repo-tracked
354
+ * `.hunch/`. Hunch's own publication pump is deliberately ADDITIVE — it refuses to stage
355
+ * a tracked deletion so a stale clone can never erase team history (the same stance
356
+ * `compact --apply` takes) — so deleting such a record here would strand `D .hunch/…` in
357
+ * the working tree and wedge every later auto-commit. The removal is a normal, reviewable
358
+ * git change the human makes instead. */
359
+ export function publicWorkspaceRemovalRecipe(id, machine) {
360
+ const path = publicWorkspaceRecordPath(id);
361
+ return ` · git rm ${path} (never committed? delete the file instead)\n`
362
+ + ` · git commit -m "hunch: forget workspace ${machine}"`;
363
+ }
349
364
  export function planPrune(live, others) {
350
365
  const mine = pruneStepsFor(live, { skip: pruneRefusal });
351
366
  const plan = { local: mine.steps, others: {}, skipped: mine.skipped };
@@ -162,6 +162,11 @@ export declare function isLinkedWorktree(cwd: string): boolean;
162
162
  /** Current branch name (e.g. "main", "feat/x"), or "" in detached HEAD / non-repo.
163
163
  * Stamped onto auto-captured decisions so branch-scoped work stays filterable. */
164
164
  export declare function currentBranch(cwd: string): string;
165
+ /** Whether HEAD is somewhere a write must not land: mid rebase/merge/cherry-pick/revert/bisect
166
+ * ("git-operation-in-progress", which wins when both hold), or detached ("detached-head").
167
+ * null when HEAD is a settled branch — and also when git cannot answer at all (not a repo,
168
+ * git missing), so a caller keeps its pre-existing behavior rather than failing closed. */
169
+ export declare function gitHeadUnsettled(cwd: string): "git-operation-in-progress" | "detached-head" | null;
165
170
  /** Files changed in a single commit. `--root` makes the initial commit (which
166
171
  * has no parent) report its files as additions instead of returning nothing. */
167
172
  export declare function commitFiles(sha: string, cwd: string): string[];
@@ -4,7 +4,7 @@ import { execFileSync } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
5
  import { devNull, tmpdir } from "node:os";
6
6
  import { isAbsolute, resolve, join, basename, dirname, relative, sep } from "node:path";
7
- import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync } from "node:fs";
7
+ import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync, existsSync } from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { MEMLOG_FORMAT } from "../core/memorylog.js";
10
10
  import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
@@ -1686,14 +1686,25 @@ export function syncExistingHunch(hunchDir, protectedRepoRoot, timeoutMs = CAPTU
1686
1686
  }
1687
1687
  }
1688
1688
  const UNKNOWN_LOCK_STALE_MS = 10 * 60_000;
1689
+ // An owner-less lock directory is also a NORMAL transient state: the window
1690
+ // between the two mkdir calls in createOwnedCommitLock, the release window where
1691
+ // recursive cleanup removes owner-<pid> before the outer directory, and a lost
1692
+ // reclaim race. This bounds how long a lock may stay owner-less before it is
1693
+ // treated as stranded. At ENTRY it is compared with the directory's mtime
1694
+ // (creating the directory, or adding/removing a child entry, touches it), so an
1695
+ // old stranded lock no-ops instantly. INSIDE the wait loop it is a local
1696
+ // continuously-owner-less timer instead, so the wait depends on neither
1697
+ // filesystem mtime semantics nor clock agreement between writers.
1698
+ const OWNERLESS_LOCK_FRESH_MS = 2_000;
1689
1699
  function createOwnedCommitLock(lock) {
1690
1700
  let created = false;
1691
1701
  try {
1692
1702
  // mkdir is the exclusive atomic operation here. Renaming a staged directory
1693
1703
  // is NOT exclusive on POSIX: it may replace an already-existing empty lock
1694
1704
  // directory, which would steal a fresh legacy/ownerless lock. There is a
1695
- // harmless ownerless window between these two mkdir calls; contenders treat
1696
- // it as held until the conservative legacy TTL expires.
1705
+ // harmless ownerless window between these two mkdir calls: it reads as held,
1706
+ // and is reclaimed only after the conservative legacy TTL expires. A capture
1707
+ // waits it out (waitForCommitLockHandoff); other callers just report busy.
1697
1708
  mkdirSync(lock);
1698
1709
  created = true;
1699
1710
  // Empty directories are not Git worktree entries, so owner metadata cannot
@@ -1781,21 +1792,66 @@ function acquireCommitLock(lock) {
1781
1792
  }
1782
1793
  return createOwnedCommitLock(lock) ? { state: "acquired" } : { state: "held-unknown" };
1783
1794
  }
1795
+ /** Is an owner-less lock directory recent enough to be a transient window
1796
+ * rather than a stranded one? Only meaningful for the FIRST snapshot, where
1797
+ * there is no local history to measure against. An unreadable directory is not
1798
+ * assumed transient: only ENOENT is, because the owner may have just finished
1799
+ * releasing. `Math.abs` so a clock-skewed far-FUTURE mtime is not read as
1800
+ * recent forever. */
1801
+ function ownerlessLockLooksTransient(lock) {
1802
+ try {
1803
+ return Math.abs(Date.now() - statSync(lock).mtimeMs) <= OWNERLESS_LOCK_FRESH_MS;
1804
+ }
1805
+ catch (error) {
1806
+ return error.code === "ENOENT";
1807
+ }
1808
+ }
1784
1809
  function waitForCommitLockHandoff(lock, first, timeoutMs) {
1785
- if (first.state !== "held-live" || first.ownerPid === process.pid)
1810
+ if (first.state === "held-live" && first.ownerPid === process.pid)
1811
+ return false;
1812
+ // An owner-less FIRST snapshot is either a transient window (creation,
1813
+ // release, lost reclaim race) or a stranded/legacy lock. Only the former is
1814
+ // worth waiting for: a stranded lock must not block the capture for the whole
1815
+ // handoff. This is the one place the WAIT POLICY consults the directory's
1816
+ // mtime; acquireCommitLock still stats it for the legacy reclaim TTL.
1817
+ if (first.state === "held-unknown" && !ownerlessLockLooksTransient(lock))
1786
1818
  return false;
1787
1819
  const deadline = Date.now() + timeoutMs;
1788
1820
  const sleeper = new Int32Array(new SharedArrayBuffer(4));
1789
1821
  let attempt = first;
1822
+ // How long the lock has read as held-unknown CONTINUOUSLY, measured locally:
1823
+ // a sighting of a live owner resets it, because a genuine release window lasts
1824
+ // milliseconds. Reading it from this clock keeps the wait independent of the
1825
+ // filesystem's mtime granularity and of other writers' clocks. Besides a truly
1826
+ // owner-less directory this also covers a dead owner whose reclaim another
1827
+ // process has claimed — equally not a handoff this contender can wait for.
1828
+ let ownerlessSince = first.state === "held-unknown" ? Date.now() : null;
1829
+ // "Held" while the directory is not even there means mkdir keeps failing for a
1830
+ // reason that is not contention — a missing, read-only or full store — and no
1831
+ // amount of waiting will hand anything off. Require a short streak so one
1832
+ // sample taken mid-release is not mistaken for it.
1833
+ let vanishedStreak = 0;
1790
1834
  while (Date.now() < deadline) {
1791
1835
  if (attempt.state === "acquired")
1792
1836
  return true;
1793
- // Once the first snapshot proved a live owner, an owner-less snapshot can be
1794
- // the normal release window: recursive cleanup removes owner-<pid> before
1795
- // removing the outer lock directory. Keep the bounded handoff wait through
1796
- // that transient state instead of reporting a false busy/no-op result.
1797
- if (attempt.state === "held-live" && attempt.ownerPid === process.pid)
1798
- return false;
1837
+ if (attempt.state === "held-live") {
1838
+ if (attempt.ownerPid === process.pid)
1839
+ return false;
1840
+ ownerlessSince = null;
1841
+ vanishedStreak = 0;
1842
+ }
1843
+ else if (attempt.state === "held-unknown") {
1844
+ ownerlessSince ??= Date.now();
1845
+ if (Date.now() - ownerlessSince > OWNERLESS_LOCK_FRESH_MS)
1846
+ return false;
1847
+ if (!existsSync(lock)) {
1848
+ vanishedStreak += 1;
1849
+ if (vanishedStreak >= 3)
1850
+ return false;
1851
+ }
1852
+ else
1853
+ vanishedStreak = 0;
1854
+ }
1799
1855
  Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()));
1800
1856
  attempt = acquireCommitLock(lock);
1801
1857
  }
@@ -1877,6 +1933,27 @@ export function currentBranch(cwd) {
1877
1933
  const b = gitSafe(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
1878
1934
  return b === "HEAD" ? "" : b; // detached HEAD reports "HEAD" — treat as no branch
1879
1935
  }
1936
+ /** The state files git writes while it is replaying history. `git rev-parse --git-path`
1937
+ * resolves each against THIS worktree's private git dir (a linked worktree keeps its own
1938
+ * rebase-merge/MERGE_HEAD), so a rebase in one worktree never reads as one in another. */
1939
+ const UNSETTLED_HEAD_PATHS = ["rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_LOG"];
1940
+ /** Whether HEAD is somewhere a write must not land: mid rebase/merge/cherry-pick/revert/bisect
1941
+ * ("git-operation-in-progress", which wins when both hold), or detached ("detached-head").
1942
+ * null when HEAD is a settled branch — and also when git cannot answer at all (not a repo,
1943
+ * git missing), so a caller keeps its pre-existing behavior rather than failing closed. */
1944
+ export function gitHeadUnsettled(cwd) {
1945
+ // One spawn: `git rev-parse --git-path a --git-path b …` prints one line per path.
1946
+ const out = gitSafe(["rev-parse", ...UNSETTLED_HEAD_PATHS.flatMap((p) => ["--git-path", p])], cwd);
1947
+ if (!out)
1948
+ return null; // git could not answer (not a repo) — fail open
1949
+ const paths = out.split("\n").map((p) => p.trim()).filter(Boolean);
1950
+ if (paths.length !== UNSETTLED_HEAD_PATHS.length)
1951
+ return null;
1952
+ if (paths.some((p) => existsSync(isAbsolute(p) ? p : resolve(cwd, p))))
1953
+ return "git-operation-in-progress";
1954
+ // `git symbolic-ref -q HEAD` exits non-zero exactly when HEAD is detached.
1955
+ return gitSafe(["symbolic-ref", "-q", "HEAD"], cwd) ? null : "detached-head";
1956
+ }
1880
1957
  /** Files changed in a single commit. `--root` makes the initial commit (which
1881
1958
  * has no parent) report its files as additions instead of returning nothing. */
1882
1959
  export function commitFiles(sha, cwd) {
@@ -51,5 +51,15 @@ export declare function assertCompleteRepoScan(scan: RepoScan): void;
51
51
  * the durable graph merely by inspecting it. Existing public graph records remain
52
52
  * inputs for the same churn/component enrichment semantics as a persisted index. */
53
53
  export declare function scanRepo(store: HunchStore, root: string, opts?: ScanRepoOptions): RepoScan;
54
+ /** True for an edge this indexer produced: the scan re-derives exactly these on
55
+ * every pass, so they — and only they — are safe to replace wholesale. */
56
+ export declare function isExtractorEdge(edge: Edge): boolean;
57
+ /** Merge a fresh scan into the stored edge set. A scan can only re-derive what
58
+ * it extracted; `supersedes` edges (written by the store) and human-reviewed
59
+ * Landscape relationships have no other source, so replacing the whole index
60
+ * with the scan deleted them on every `hunch index` (issue #288). Non-extractor
61
+ * edges are carried forward in their stored order and win an id collision:
62
+ * a reviewed or store-written fact outranks a re-derivable one. */
63
+ export declare function mergeScannedEdges(stored: Edge[], scanned: Edge[]): Edge[];
54
64
  /** Persist one pure scan into the Git-native source of truth. */
55
65
  export declare function indexRepo(store: HunchStore, root: string, opts?: IndexRepoOptions): IndexResult;