@davesheffer/hunch 1.33.0 → 1.35.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.
@@ -0,0 +1,185 @@
1
+ /** Finished tasks as graph memory: `.hunch/tasks/htask_*.json`.
2
+ *
3
+ * The observation ledger (`.hunch-cache/served.db`) is machine-local and expires;
4
+ * the graph is what Hunch knows. When a task finishes with at least one
5
+ * observation, a bounded summary of it is written through the SAME capture path
6
+ * as decisions and findings (`HunchStore.putCapture`), so public/private homing,
7
+ * the one-home-per-record rule, auto-commit and team routing apply unchanged.
8
+ *
9
+ * Empty tasks (nothing delivered, saved, checked, claimed or denied) stay
10
+ * ledger-only: a record per bare prompt would be commit noise, not memory.
11
+ * The record carries titles and record references only — never prompt text,
12
+ * transcript, private context payloads or denial reasons. */
13
+ import { readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { flushCapture } from "../integrations/sync.js";
16
+ import { hunchPaths } from "./paths.js";
17
+ import { isEmptyTaskReport, readTaskReport, reportHash } from "./taskReport.js";
18
+ import { reportSourceSnapshot } from "./taskReportEvidence.js";
19
+ import { ENTITY_KINDS, TaskRecordSchema } from "./types.js";
20
+ function localConfig(root) {
21
+ try {
22
+ return JSON.parse(readFileSync(join(root, ".hunch", "local.json"), "utf8"));
23
+ }
24
+ catch {
25
+ return {};
26
+ }
27
+ }
28
+ /** `taskRecords: false` in `.hunch/local.json` keeps tasks ledger-only. */
29
+ export function taskRecordsEnabled(root) {
30
+ return localConfig(root).taskRecords !== false;
31
+ }
32
+ /** `taskRecordsFlush: "batch"` writes the record but leaves the commit to the
33
+ * next capture flush (decision, finding, correction), so a busy repository
34
+ * gets one memory commit per real capture instead of one per prompt. Default
35
+ * "each": every finished task commits like any other capture. */
36
+ export function taskRecordFlushMode(root) {
37
+ return localConfig(root).taskRecordsFlush === "batch" ? "batch" : "each";
38
+ }
39
+ /** A delivery target that names code (a path or dotted symbol), not a task
40
+ * phrase like "fix the login redirect". Phrases never become file anchors. */
41
+ export function targetLooksLikePath(target) {
42
+ const t = target.trim();
43
+ if (!t || t.length > 512 || /\s/.test(t))
44
+ return false;
45
+ if (!/^[A-Za-z0-9_@$./\\:-]+$/.test(t))
46
+ return false;
47
+ return /[./\\]/.test(t) && !/^\.+$/.test(t);
48
+ }
49
+ /** The durable summary of a finished report, or null when there is nothing to keep. */
50
+ export function taskRecordFromReport(report) {
51
+ const { task } = report;
52
+ if (task.state === "open" || !task.finished_at)
53
+ return null;
54
+ if (isEmptyTaskReport(report))
55
+ return null;
56
+ const lessons = new Map();
57
+ for (const delivery of report.deliveries) {
58
+ for (const r of delivery.records) {
59
+ 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) });
60
+ }
61
+ }
62
+ const files = new Set();
63
+ for (const d of report.deliveries)
64
+ if (d.target && targetLooksLikePath(d.target))
65
+ files.add(d.target.trim().replace(/\\/g, "/"));
66
+ for (const c of report.conformance)
67
+ for (const f of c.files)
68
+ files.add(f);
69
+ for (const r of report.refusals)
70
+ files.add(r.target);
71
+ const latestRule = new Map();
72
+ for (const c of report.conformance) {
73
+ 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 });
74
+ }
75
+ const lastCheck = report.checks.at(-1);
76
+ return TaskRecordSchema.parse({
77
+ id: task.task_id,
78
+ title: task.title,
79
+ state: task.state,
80
+ started_at: task.started_at,
81
+ finished_at: task.finished_at,
82
+ coverage: report.coverage,
83
+ lessons: [...lessons.values()],
84
+ 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 })),
85
+ 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 })),
86
+ 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 })),
87
+ conformance: [...latestRule.values()],
88
+ refusals: report.refusals.length,
89
+ files: [...files].sort().slice(0, 64),
90
+ source_snapshot: lastCheck?.after_snapshot ?? null,
91
+ report_hash: report.content_hash,
92
+ provenance: { source: "task_report", confidence: 1, evidence: [`hunch report ${task.task_id}`], last_verified: task.finished_at },
93
+ });
94
+ }
95
+ /** Where the record belongs. Anything that touched the private overlay — a
96
+ * private save, or a delivered lesson that lives only there — must not be named
97
+ * in a public record; the store's own routing (unified/shared mode) wins first. */
98
+ export function taskRecordHome(store, record) {
99
+ if (store.captureHome(false) === "private")
100
+ return "private";
101
+ if (!store.hasPrivate)
102
+ return "public";
103
+ if (record.saved.some((s) => s.home === "private"))
104
+ return "private";
105
+ for (const lesson of record.lessons) {
106
+ if (!ENTITY_KINDS.includes(lesson.kind))
107
+ continue;
108
+ const kind = lesson.kind;
109
+ if (store.getPrivateRec(kind, lesson.record_id) && !store.json.get(kind, lesson.record_id))
110
+ return "private";
111
+ }
112
+ return "public";
113
+ }
114
+ /** Write (or refresh) the graph record for a finished task. Idempotent on the
115
+ * report hash. A record never changes home once written. Returns null for an
116
+ * open task, an empty report, or when task records are disabled locally. */
117
+ export function persistTaskRecord(root, store, taskId, options = {}) {
118
+ if (!taskRecordsEnabled(root))
119
+ return null;
120
+ const report = readTaskReport(root, taskId, reportSourceSnapshot(root).hash);
121
+ const record = taskRecordFromReport(report);
122
+ if (!record)
123
+ return null;
124
+ const inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", record.id) : undefined;
125
+ const inPublic = store.json.get("tasks", record.id);
126
+ const home = inPrivate ? "private" : inPublic ? "public" : taskRecordHome(store, record);
127
+ const existing = home === "private" ? inPrivate : inPublic;
128
+ if (existing && existing.report_hash === record.report_hash)
129
+ return { record: existing, home, flushed: null, changed: false };
130
+ const stored = store.putCapture("tasks", record, home === "private");
131
+ store.reindex();
132
+ const flushNow = options.flush ?? taskRecordFlushMode(root) === "each";
133
+ const flushed = flushNow ? flushCapture(store, hunchPaths(root).hunch, home === "private", `hunch: task ${record.id}`) : null;
134
+ return { record: stored, home, flushed, changed: true };
135
+ }
136
+ const GRAPH_SCOPE = reportHash("graph-record");
137
+ /** A ledger-shaped summary for a task known only from the graph (another
138
+ * machine, a teammate, or a pruned local ledger). */
139
+ export function summaryFromTaskRecord(record, home) {
140
+ const last = record.checks.at(-1);
141
+ return {
142
+ task: { task_id: record.id, scope: GRAPH_SCOPE, title: record.title, started_at: record.started_at, finished_at: record.finished_at, state: record.state },
143
+ deliveries: record.lessons.length ? 1 : 0,
144
+ lessons: new Set(record.lessons.map((l) => `${l.kind}:${l.record_id}`)).size,
145
+ claims: record.applied.length,
146
+ saves: record.saved.length,
147
+ refusals: record.refusals,
148
+ check: last ? { label: last.label, state: last.state, current: false } : null,
149
+ violated: record.conformance.some((c) => c.outcome === "violated"),
150
+ coverage: record.coverage,
151
+ empty: false,
152
+ report_html: null,
153
+ error: null,
154
+ durable: { home },
155
+ };
156
+ }
157
+ /** Ledger summaries annotated with their graph home, plus graph-only tasks the
158
+ * local ledger never saw. Newest first, bounded. */
159
+ export function mergeDurableTaskSummaries(store, summaries, limit = 30) {
160
+ const homes = new Map();
161
+ const records = new Map();
162
+ for (const r of store.recsInHome("tasks", "public")) {
163
+ homes.set(r.id, "public");
164
+ records.set(r.id, r);
165
+ }
166
+ if (store.hasPrivate)
167
+ for (const r of store.recsInHome("tasks", "private")) {
168
+ homes.set(r.id, "private");
169
+ records.set(r.id, r);
170
+ }
171
+ const seen = new Set();
172
+ const merged = summaries.map((s) => {
173
+ seen.add(s.task.task_id);
174
+ const home = homes.get(s.task.task_id);
175
+ return { ...s, durable: home ? { home } : null };
176
+ });
177
+ for (const r of records.values()) {
178
+ if (seen.has(r.id))
179
+ continue;
180
+ merged.push(summaryFromTaskRecord(r, homes.get(r.id) ?? "public"));
181
+ }
182
+ merged.sort((a, b) => b.task.started_at.localeCompare(a.task.started_at));
183
+ return merged.slice(0, Math.max(1, limit));
184
+ }
185
+ //# sourceMappingURL=taskRecord.js.map
@@ -132,6 +132,9 @@ export interface TaskDelivery {
132
132
  envelope_hash: string;
133
133
  envelope: DeliveryEnvelope;
134
134
  records: ReportRecord[];
135
+ /** What the caller asked context for (a file, symbol or task phrase); null for
136
+ * deliveries recorded by releases that did not retain it. */
137
+ target: string | null;
135
138
  }
136
139
  export interface TaskReport {
137
140
  schema: typeof TASK_REPORT_SCHEMA;
@@ -206,7 +209,7 @@ export declare function startReportTask(root: string, title: string, taskId?: st
206
209
  export declare function unseenLessons(root: string, taskId: string, records: readonly ReportRecord[]): ReportRecord[];
207
210
  /** Strict operation for explicit callers. Passive integrations catch failure
208
211
  * and disclose it without blocking context delivery. Empty envelopes count. */
209
- export declare function recordTaskDelivery(root: string, taskId: string, envelope: DeliveryEnvelope, records: ReportRecord[], occurrenceId?: string): string;
212
+ export declare function recordTaskDelivery(root: string, taskId: string, envelope: DeliveryEnvelope, records: ReportRecord[], occurrenceId?: string, target?: string): string;
210
213
  export declare function recordReportClaim(root: string, taskId: string, claim: ReportClaim): string;
211
214
  /** Internal write-path observers; no MCP endpoint accepts fabricated saves or
212
215
  * publication claims. Retained observations never authorize memory mutations. */
@@ -247,6 +250,10 @@ export interface TaskSummary {
247
250
  report_html: string | null;
248
251
  /** Set when the observation ledger could not be read for this task. */
249
252
  error: string | null;
253
+ /** Set when the task has a graph record (.hunch/tasks/), and where it lives. */
254
+ durable?: {
255
+ home: "public" | "private";
256
+ } | null;
250
257
  }
251
258
  /** One bounded summary per recent task for status lines and host views; the
252
259
  * card and evidence view remain the authoritative renderings. */
@@ -7,7 +7,7 @@ import { z } from "zod";
7
7
  import { assertDeliveryEnvelope } from "./delivery.js";
8
8
  import { isCredentialFreeText } from "./types.js";
9
9
  import { withServedDatabase } from "./served.js";
10
- import { assertReportPath } from "./taskReportPaths.js";
10
+ import { assertReportPath, canonicalReportRoot } from "./taskReportPaths.js";
11
11
  export const TASK_REPORT_SCHEMA = "hunch.task-report/1";
12
12
  export const TaskIdSchema = z.string().regex(/^htask_[a-f0-9]{24}$/);
13
13
  const hashSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
@@ -175,9 +175,9 @@ export function readLessonHistory(root, reference, options = {}) {
175
175
  const rows = db.prepare(`SELECT e.rowid AS seq, e.event_id, e.kind, e.at, e.body, e.content_hash, t.body AS task
176
176
  FROM report_record_links l JOIN report_events e ON e.event_id = l.event_id
177
177
  JOIN report_tasks t ON t.task_id = e.task_id
178
- WHERE t.scope = ? AND l.kind = ? AND l.record_id = ?
178
+ WHERE t.scope IN (?, ?, ?) AND l.kind = ? AND l.record_id = ?
179
179
  AND (? IS NULL OR l.content_hash = ?) AND e.rowid < ?
180
- ORDER BY e.rowid DESC LIMIT ?`).all(scopeOf(root), ref.kind, ref.record_id, ref.content_hash ?? null, ref.content_hash ?? null, before ?? Number.MAX_SAFE_INTEGER, limit + 1);
180
+ ORDER BY e.rowid DESC LIMIT ?`).all(...scopePair(root), ref.kind, ref.record_id, ref.content_hash ?? null, ref.content_hash ?? null, before ?? Number.MAX_SAFE_INTEGER, limit + 1);
181
181
  return { more, rows };
182
182
  }, true);
183
183
  const entries = rows.slice(0, limit).map(row => {
@@ -196,10 +196,21 @@ export function readLessonHistory(root, reference, options = {}) {
196
196
  return { schema: "hunch.lesson-history/1", reference: ref, entries, index_complete: !more, truncated: rows.length > limit, next_before: !more && rows.length > limit ? rows[limit - 1].seq : null };
197
197
  });
198
198
  }
199
- function scopeOf(root) { return reportHash(realpathSync(root)); }
199
+ /** Task scope = the physical repository root. Rows written by releases that hashed
200
+ * the caller-cased realpath stay readable until they expire: the caller's own
201
+ * spelling plus, on Windows, the other drive-letter case (a hook and an MCP
202
+ * server spawned by the same host commonly disagree on exactly that). */
203
+ function scopeOf(root) { return reportHash(canonicalReportRoot(root)); }
204
+ function scopePair(root) {
205
+ const legacy = realpathSync(root);
206
+ const swapped = /^[A-Za-z]:/.test(legacy)
207
+ ? (legacy.charAt(0) === legacy.charAt(0).toLowerCase() ? legacy.charAt(0).toUpperCase() : legacy.charAt(0).toLowerCase()) + legacy.slice(1)
208
+ : legacy;
209
+ return [scopeOf(root), reportHash(legacy), reportHash(swapped)];
210
+ }
200
211
  function readTask(db, root, id) {
201
212
  TaskIdSchema.parse(id);
202
- const row = db.prepare("SELECT body FROM report_tasks WHERE task_id = ? AND scope = ?").get(id, scopeOf(root));
213
+ const row = db.prepare("SELECT body FROM report_tasks WHERE task_id = ? AND scope IN (?, ?, ?)").get(id, ...scopePair(root));
203
214
  if (!row)
204
215
  throw new Error("task not found in this repository/worktree; use its exact task ID and working directory");
205
216
  return TaskSchema.parse(JSON.parse(row.body));
@@ -298,7 +309,7 @@ export function unseenLessons(root, taskId, records) {
298
309
  }
299
310
  /** Strict operation for explicit callers. Passive integrations catch failure
300
311
  * and disclose it without blocking context delivery. Empty envelopes count. */
301
- export function recordTaskDelivery(root, taskId, envelope, records, occurrenceId = `hocc_${randomBytes(12).toString("hex")}`) {
312
+ export function recordTaskDelivery(root, taskId, envelope, records, occurrenceId = `hocc_${randomBytes(12).toString("hex")}`, target) {
302
313
  assertDeliveryEnvelope(envelope);
303
314
  const snapshots = z.array(ReportRecordSchema).max(512).parse(records);
304
315
  const seen = new Set();
@@ -308,7 +319,10 @@ export function recordTaskDelivery(root, taskId, envelope, records, occurrenceId
308
319
  throw new Error("snapshot is duplicated or was not delivered");
309
320
  seen.add(key);
310
321
  }
311
- return appendEvent(root, taskId, "delivery", { envelope, records: snapshots }, occurrenceId);
322
+ // The target is optional so envelopes recorded without one keep their exact
323
+ // event hash; it is bounded like any other retained text.
324
+ const retainedTarget = typeof target === "string" && target.trim() && target.length <= 1024 && isCredentialFreeText(target) ? target : undefined;
325
+ return appendEvent(root, taskId, "delivery", { envelope, records: snapshots, ...(retainedTarget ? { target: retainedTarget } : {}) }, occurrenceId);
312
326
  }
313
327
  export function recordReportClaim(root, taskId, claim) {
314
328
  const value = ReportClaimSchema.parse(claim);
@@ -440,7 +454,7 @@ export function taskReportStats(root, days = 7) {
440
454
  if (!existsSync(join(root, ".hunch-cache", "served.db")))
441
455
  return empty;
442
456
  return taskDb(root, db => {
443
- const tasks = db.prepare("SELECT body FROM report_tasks WHERE scope = ? AND json_extract(body, '$.started_at') >= ?").all(scopeOf(root), since)
457
+ const tasks = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.started_at') >= ?").all(...scopePair(root), since)
444
458
  .map(r => TaskSchema.parse(JSON.parse(r.body)));
445
459
  if (!tasks.length)
446
460
  return empty;
@@ -468,15 +482,15 @@ export function taskReportStats(root, days = 7) {
468
482
  });
469
483
  }
470
484
  export function listReportTasks(root) {
471
- return taskDb(root, db => db.prepare("SELECT body FROM report_tasks WHERE scope = ? ORDER BY rowid DESC LIMIT 30").all(scopeOf(root)).map(r => TaskSchema.parse(JSON.parse(r.body))));
485
+ return taskDb(root, db => db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) ORDER BY rowid DESC LIMIT 30").all(...scopePair(root)).map(r => TaskSchema.parse(JSON.parse(r.body))));
472
486
  }
473
487
  export function reportActivity(root) {
474
488
  if (!existsSync(join(root, ".hunch-cache", "served.db")))
475
489
  return "Task reporting: no task activity observed yet. Reconnect the agent after updating; inspect with `hunch report`.";
476
490
  try {
477
491
  return taskDb(root, db => {
478
- const row = db.prepare(`SELECT COUNT(*) AS total, SUM(CASE WHEN json_extract(body, '$.state') = 'completed' THEN 1 ELSE 0 END) AS completed FROM report_tasks WHERE scope = ?`).get(scopeOf(root));
479
- const { deliveries } = db.prepare("SELECT COUNT(*) AS deliveries FROM report_events WHERE kind = 'delivery' AND task_id IN (SELECT task_id FROM report_tasks WHERE scope = ?)").get(scopeOf(root));
492
+ const row = db.prepare(`SELECT COUNT(*) AS total, SUM(CASE WHEN json_extract(body, '$.state') = 'completed' THEN 1 ELSE 0 END) AS completed FROM report_tasks WHERE scope IN (?, ?, ?)`).get(...scopePair(root));
493
+ const { deliveries } = db.prepare("SELECT COUNT(*) AS deliveries FROM report_events WHERE kind = 'delivery' AND task_id IN (SELECT task_id FROM report_tasks WHERE scope IN (?, ?, ?))").get(...scopePair(root));
480
494
  return `Task reporting: ${row.total} observed task(s), ${row.completed ?? 0} completed report(s), ${deliveries} linked context delivery(s). Activity alone does not prove contribution; inspect with \`hunch report\`.`;
481
495
  });
482
496
  }
@@ -505,9 +519,9 @@ export function pruneReportHistory(root, olderThanDays = 90) {
505
519
  const cutoff = new Date(Date.now() - olderThanDays * 86_400_000).toISOString();
506
520
  return taskDb(root, db => transaction(db, () => {
507
521
  const expired = db.prepare(`SELECT task_id FROM report_tasks
508
- WHERE scope = ? AND ((json_extract(body, '$.state') != 'open' AND json_extract(body, '$.finished_at') < ?)
522
+ WHERE scope IN (?, ?, ?) AND ((json_extract(body, '$.state') != 'open' AND json_extract(body, '$.finished_at') < ?)
509
523
  OR (json_extract(body, '$.state') = 'open' AND json_extract(body, '$.started_at') < ?)) LIMIT 1000`)
510
- .all(scopeOf(root), cutoff, cutoff);
524
+ .all(...scopePair(root), cutoff, cutoff);
511
525
  for (const task of expired) {
512
526
  // Serialize expiry with concurrent starts/results. Discard abandoned
513
527
  // evidence without inventing a completion or interruption observation.
@@ -552,7 +566,7 @@ export function readTaskReport(root, taskId, currentSnapshot = null) {
552
566
  throw new Error("report evidence hash mismatch");
553
567
  if (event.kind === "delivery") {
554
568
  assertDeliveryEnvelope(value.envelope);
555
- deliveries.push({ occurrence_id: event.event_id, at: event.at, receipt_id: value.envelope.receipt_id, envelope_hash: reportHash(value.envelope), envelope: value.envelope, records: z.array(ReportRecordSchema).parse(value.records) });
569
+ deliveries.push({ occurrence_id: event.event_id, at: event.at, receipt_id: value.envelope.receipt_id, envelope_hash: reportHash(value.envelope), envelope: value.envelope, records: z.array(ReportRecordSchema).parse(value.records), target: typeof value.target === "string" ? value.target : null });
556
570
  }
557
571
  else if (event.kind === "claim")
558
572
  claims.push({ ...ReportClaimSchema.parse(value), at: event.at, attribution: "agent-reported", supported_by: null });
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { execFile, execFileSync, spawn } from "node:child_process";
3
3
  import { lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
4
+ import { canonicalReportRoot } from "./taskReportPaths.js";
4
5
  import { dirname, isAbsolute, relative, resolve } from "node:path";
5
6
  import { analyzeDiff } from "../extractors/diff.js";
6
7
  import { workingDiff, workingFiles } from "../extractors/git.js";
@@ -32,7 +33,7 @@ export function snapshotDeliveredRecords(store, envelope) {
32
33
  export function reportSourceSnapshot(root) {
33
34
  const limitations = ["Git-ignored files, Hunch memory/cache, and external dependencies are outside this source snapshot."];
34
35
  try {
35
- const base = realpathSync(root);
36
+ const base = canonicalReportRoot(root);
36
37
  const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
37
38
  for (const key of Object.keys(env))
38
39
  if (key.startsWith("GIT_") && key !== "GIT_OPTIONAL_LOCKS")
@@ -1,10 +1,25 @@
1
1
  import type { HookProvider, HunchHookInput } from "./agenthook.js";
2
- /** The exact task identity a Claude Code prompt maps to. The status line receives
3
- * the same session_id/prompt_id on stdin, so it can name the prompt's task too. */
2
+ /** The exact task identity a native host prompt maps to. */
4
3
  export declare function promptTaskId(root: string, sessionId: string, promptId: string, agentId?: string | null, provider?: HookProvider): string;
4
+ /** Prompt-derived titles are OPT-IN (`"taskTitles": "prompt"` in .hunch/local.json).
5
+ * The default keeps the documented guarantee that no prompt text is retained
6
+ * anywhere: not in the ledger, the Stop card, the Contribution view, nor a graph
7
+ * record that may be committed to a public repository. */
8
+ export declare function promptTitlesEnabled(root: string): boolean;
9
+ /** A short, safe task title from the prompt's first line: control characters
10
+ * and runs of whitespace collapse, credential-looking text is refused, and the
11
+ * result is cut at a word boundary. Null means "use the generic title". The
12
+ * title is the only prompt-derived prose that reaches the ledger and, on
13
+ * finish, the graph record. */
14
+ export declare function promptTaskTitle(prompt: string | undefined): string | null;
15
+ /** Carry a hook-observed working directory into MCP only after proving it names
16
+ * the same physical repository as the hook process. The canonical repository
17
+ * root is stable across cwd subdirectories and safe to copy into a tool call. */
18
+ export declare function nativeHookCwd(root: string, provider: HookProvider, event: HunchHookInput): string | null;
5
19
  export declare function hookReportTaskId(root: string, provider: HookProvider, event: HunchHookInput): string | null;
6
20
  /** Every prompt receives its exact ID, even when ambient reminders were deduped.
7
- * No raw prompt, host session identifier, or transcript is retained. */
21
+ * No raw prompt, host session identifier, or transcript is retained; a repository
22
+ * that opts in (`taskTitles: "prompt"`) keeps only a bounded first-line title. */
8
23
  export declare function startHookReport(root: string, provider: HookProvider, event: HunchHookInput): string | null;
9
24
  /** A presentation notice never denies Stop or injects another model turn. Stop
10
25
  * can precede another hook's continuation, so it does not close an open task.
@@ -1,21 +1,70 @@
1
1
  /** Native lifecycle coverage is independent of whether a model follows reporting
2
2
  * instructions. Only an authoritative prompt identity may join its evidence. */
3
- import { realpathSync } from "node:fs";
4
3
  import { pathToFileURL } from "node:url";
4
+ import { readFileSync } from "node:fs";
5
+ import { join } from "node:path";
5
6
  import { findRoot } from "./paths.js";
7
+ import { canonicalReportRoot } from "./taskReportPaths.js";
8
+ import { isCredentialFreeText } from "./types.js";
6
9
  import { isEmptyTaskReport, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, startReportTask } from "./taskReport.js";
7
10
  import { reportSourceSnapshot } from "./taskReportEvidence.js";
8
11
  import { renderTaskReport, writeTaskReportHtml } from "./taskReportRender.js";
9
- /** The exact task identity a Claude Code prompt maps to. The status line receives
10
- * the same session_id/prompt_id on stdin, so it can name the prompt's task too. */
12
+ /** The exact task identity a native host prompt maps to. */
11
13
  export function promptTaskId(root, sessionId, promptId, agentId = null, provider = "claude") {
12
- return `htask_${reportHash([realpathSync(root), provider, sessionId, promptId, agentId]).slice(7, 31)}`;
14
+ return `htask_${reportHash([canonicalReportRoot(root), provider, sessionId, promptId, agentId]).slice(7, 31)}`;
13
15
  }
14
16
  /** Hosts whose hooks deliver a native per-prompt identity (Claude Code's
15
17
  * prompt_id, Codex's turn_id). Others get no task from a hook. */
16
18
  const NATIVE_PROMPT_HOSTS = new Set(["claude", "codex"]);
19
+ const NATIVE_TASK_TITLE = "Assistant task";
20
+ const GENERIC_TASK_TITLES = new Set(["Assistant task", "Claude task"]);
21
+ const TASK_TITLE_MAX = 72;
22
+ /** Prompt-derived titles are OPT-IN (`"taskTitles": "prompt"` in .hunch/local.json).
23
+ * The default keeps the documented guarantee that no prompt text is retained
24
+ * anywhere: not in the ledger, the Stop card, the Contribution view, nor a graph
25
+ * record that may be committed to a public repository. */
26
+ export function promptTitlesEnabled(root) {
27
+ try {
28
+ return JSON.parse(readFileSync(join(root, ".hunch", "local.json"), "utf8")).taskTitles === "prompt";
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ /** A short, safe task title from the prompt's first line: control characters
35
+ * and runs of whitespace collapse, credential-looking text is refused, and the
36
+ * result is cut at a word boundary. Null means "use the generic title". The
37
+ * title is the only prompt-derived prose that reaches the ledger and, on
38
+ * finish, the graph record. */
39
+ export function promptTaskTitle(prompt) {
40
+ if (typeof prompt !== "string")
41
+ return null;
42
+ const firstLine = prompt.split(/\r?\n/).map(l => l.trim()).find(l => l.length > 0) ?? "";
43
+ const clean = firstLine.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
44
+ if (clean.length < 3 || !isCredentialFreeText(clean) || !isCredentialFreeText(prompt.slice(0, 4096)))
45
+ return null;
46
+ if (clean.length <= TASK_TITLE_MAX)
47
+ return clean;
48
+ const cut = clean.slice(0, TASK_TITLE_MAX);
49
+ const atWord = cut.lastIndexOf(" ");
50
+ return `${(atWord > TASK_TITLE_MAX / 2 ? cut.slice(0, atWord) : cut).trimEnd()}…`;
51
+ }
52
+ /** Carry a hook-observed working directory into MCP only after proving it names
53
+ * the same physical repository as the hook process. The canonical repository
54
+ * root is stable across cwd subdirectories and safe to copy into a tool call. */
55
+ export function nativeHookCwd(root, provider, event) {
56
+ if (!NATIVE_PROMPT_HOSTS.has(provider) || !event.cwd)
57
+ return null;
58
+ try {
59
+ const physicalRoot = canonicalReportRoot(root);
60
+ return canonicalReportRoot(findRoot(event.cwd)) === physicalRoot ? physicalRoot : null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
17
66
  function identity(root, provider, event) {
18
- if (!NATIVE_PROMPT_HOSTS.has(provider) || !event.cwd || realpathSync(findRoot(event.cwd)) !== realpathSync(root))
67
+ if (!nativeHookCwd(root, provider, event))
19
68
  return null;
20
69
  for (const value of [event.session_id, event.prompt_id, event.agent_id]) {
21
70
  if (value !== undefined && (!value.length || value.length > 1024 || /[\u0000-\u001f\u007f]/.test(value)))
@@ -37,13 +86,33 @@ export function hookReportTaskId(root, provider, event) {
37
86
  }
38
87
  }
39
88
  /** Every prompt receives its exact ID, even when ambient reminders were deduped.
40
- * No raw prompt, host session identifier, or transcript is retained. */
89
+ * No raw prompt, host session identifier, or transcript is retained; a repository
90
+ * that opts in (`taskTitles: "prompt"`) keeps only a bounded first-line title. */
41
91
  export function startHookReport(root, provider, event) {
42
92
  const id = identity(root, provider, event);
43
93
  if (!id || id === "legacy")
44
94
  return null;
45
- const task = startReportTask(root, "Claude task", id);
46
- 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: "Claude task") to obtain verification_argv; do not create another report. Pass this task_id to hunch_context and decision/correction/finding captures, and finish with hunch_task before responding. A host Stop notice will show the evidence even if no task-linked memory was observed.`;
95
+ // Re-resolve after identity validation and fail closed if the filesystem
96
+ // changed between the two reads; never emit a task instruction with cwd:null.
97
+ const cwd = nativeHookCwd(root, provider, event);
98
+ if (!cwd)
99
+ return null;
100
+ const cwdLiteral = JSON.stringify(cwd);
101
+ const title = (promptTitlesEnabled(root) ? promptTaskTitle(event.prompt) : null) ?? NATIVE_TASK_TITLE;
102
+ let task;
103
+ try {
104
+ task = startReportTask(root, title, id);
105
+ }
106
+ catch (error) {
107
+ // The same prompt identity may already be open: a release that called every
108
+ // native task "Claude task"/"Assistant task", or a second hook registration
109
+ // for the same host. The persisted identity and title win; never a second task.
110
+ const existing = readTaskReport(root, id).task;
111
+ if (existing.title !== title && !GENERIC_TASK_TITLES.has(existing.title) && !GENERIC_TASK_TITLES.has(title))
112
+ throw error;
113
+ task = existing;
114
+ }
115
+ 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.`;
47
116
  }
48
117
  /** A presentation notice never denies Stop or injects another model turn. Stop
49
118
  * can precede another hook's continuation, so it does not close an open task.
@@ -1,3 +1,9 @@
1
+ /** The ONE physical identity of a repository root for task scoping. Node's
2
+ * realpathSync keeps whatever drive-letter/segment casing the caller passed
3
+ * (a lowercase vs uppercase drive letter), so a hook spawned with one spelling and an MCP
4
+ * server spawned with the other hashed to two scopes for the same checkout and
5
+ * every task lookup failed. The native resolver returns on-disk casing. */
6
+ export declare function canonicalReportRoot(root: string): string;
1
7
  /** Report paths are local to this physical worktree. Existing symlinks and
2
8
  * hard-linked files are refused; never follow a cache pointer into another scope. */
3
9
  export declare function assertReportPath(root: string, ...parts: string[]): string;
@@ -1,5 +1,18 @@
1
1
  import { lstatSync, realpathSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ /** The ONE physical identity of a repository root for task scoping. Node's
4
+ * realpathSync keeps whatever drive-letter/segment casing the caller passed
5
+ * (a lowercase vs uppercase drive letter), so a hook spawned with one spelling and an MCP
6
+ * server spawned with the other hashed to two scopes for the same checkout and
7
+ * every task lookup failed. The native resolver returns on-disk casing. */
8
+ export function canonicalReportRoot(root) {
9
+ try {
10
+ return realpathSync.native(root);
11
+ }
12
+ catch {
13
+ return realpathSync(root);
14
+ }
15
+ }
3
16
  /** Report paths are local to this physical worktree. Existing symlinks and
4
17
  * hard-linked files are refused; never follow a cache pointer into another scope. */
5
18
  export function assertReportPath(root, ...parts) {