@davesheffer/hunch 1.32.3 → 1.32.5

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 (40) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/index.js +40 -7
  3. package/dist/cli/taskReport.js +80 -1
  4. package/dist/constitution/behaviorEvaluator.js +1 -1
  5. package/dist/constitution/repository.d.ts +1 -0
  6. package/dist/constitution/repository.js +68 -42
  7. package/dist/core/agenthook.d.ts +1 -1
  8. package/dist/core/agenthook.js +30 -6
  9. package/dist/core/checkreport.js +1 -1
  10. package/dist/core/config.d.ts +3 -0
  11. package/dist/core/config.js +4 -1
  12. package/dist/core/events.js +19 -3
  13. package/dist/core/io.js +30 -19
  14. package/dist/core/jsonc.js +13 -3
  15. package/dist/core/storeArtifact.d.ts +7 -0
  16. package/dist/core/storeArtifact.js +62 -0
  17. package/dist/core/taskReport.d.ts +49 -0
  18. package/dist/core/taskReport.js +99 -0
  19. package/dist/core/taskReportHook.d.ts +7 -1
  20. package/dist/core/taskReportHook.js +18 -5
  21. package/dist/integrations/claudeConfig.js +20 -3
  22. package/dist/integrations/claudemd.js +1 -1
  23. package/dist/integrations/health.d.ts +19 -5
  24. package/dist/integrations/health.js +36 -11
  25. package/dist/integrations/providers.d.ts +7 -0
  26. package/dist/integrations/providers.js +85 -16
  27. package/dist/integrations/registry.d.ts +15 -0
  28. package/dist/integrations/registry.js +41 -0
  29. package/dist/integrations/scaffold.js +17 -3
  30. package/dist/mcp/server.d.ts +4 -0
  31. package/dist/mcp/server.js +350 -324
  32. package/dist/mcp/toolset.d.ts +30 -0
  33. package/dist/mcp/toolset.js +72 -0
  34. package/dist/serve/app.js +9 -6
  35. package/dist/serve/writelock.js +8 -2
  36. package/dist/store/changeLedger.js +7 -6
  37. package/dist/store/jsonStore.d.ts +3 -3
  38. package/dist/store/jsonStore.js +65 -14
  39. package/package.json +1 -1
  40. package/server.json +2 -2
package/dist/core/io.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Durable file writes for the Hunch. */
2
- import { closeSync, fsyncSync, linkSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
2
+ import { closeSync, fchmodSync, fsyncSync, linkSync, lstatSync, openSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  let counter = 0;
5
5
  const renameRetryDelaysMs = [10, 20, 40, 80];
@@ -24,20 +24,17 @@ const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_
24
24
  */
25
25
  export function writeFileAtomic(file, data) {
26
26
  const tmp = `${file}.tmp${process.pid}.${counter++}`;
27
+ let mode;
27
28
  try {
28
- const fd = openSync(tmp, "w");
29
- try {
30
- writeSync(fd, data);
31
- fsyncSync(fd); // data blocks reach disk before the rename's metadata can
32
- }
33
- finally {
34
- closeSync(fd);
35
- }
29
+ const existing = lstatSync(file);
30
+ if (existing.isFile())
31
+ mode = existing.mode & 0o777;
36
32
  }
37
- catch (e) {
38
- cleanupTmp(tmp);
39
- throw e;
33
+ catch (error) {
34
+ if (error.code !== "ENOENT")
35
+ throw error;
40
36
  }
37
+ writeFileAtomicTmp(tmp, data, mode);
41
38
  try {
42
39
  renameWithContentionRetry(tmp, file);
43
40
  }
@@ -85,8 +82,10 @@ function isRenameContention(error) {
85
82
  * semantics, so concurrent lifecycle writers can never be overwritten. */
86
83
  export function writeFileAtomicIfAbsent(file, data) {
87
84
  const tmp = `${file}.tmp${process.pid}.${counter++}`;
85
+ // An occupied temp path is an error, not evidence that the target exists.
86
+ // Only enter the publication/cleanup block once we own the temporary file.
87
+ writeFileAtomicTmp(tmp, data);
88
88
  try {
89
- writeFileAtomicTmp(tmp, data);
90
89
  linkSync(tmp, file);
91
90
  return true;
92
91
  }
@@ -104,14 +103,26 @@ export function writeFileAtomicIfAbsent(file, data) {
104
103
  }
105
104
  }
106
105
  /** Write + fsync a fresh temp file (shared by both atomic writers). */
107
- function writeFileAtomicTmp(tmp, data) {
108
- const fd = openSync(tmp, "w");
106
+ function writeFileAtomicTmp(tmp, data, mode) {
107
+ // Exclusive creation rejects stale files and links without truncating their
108
+ // contents. If open fails, the path belongs to somebody else: never unlink it.
109
+ const fd = openSync(tmp, "wx", mode ?? 0o666);
109
110
  try {
110
- writeSync(fd, data);
111
- fsyncSync(fd);
111
+ try {
112
+ writeFileSync(fd, data);
113
+ // The replacement inode must retain an existing file's permissions,
114
+ // including private config files that contain credentials.
115
+ if (mode !== undefined)
116
+ fchmodSync(fd, mode);
117
+ fsyncSync(fd);
118
+ }
119
+ finally {
120
+ closeSync(fd);
121
+ }
112
122
  }
113
- finally {
114
- closeSync(fd);
123
+ catch (error) {
124
+ cleanupTmp(tmp);
125
+ throw error;
115
126
  }
116
127
  }
117
128
  /** Remove a temp file, riding out a transient external hold (AV/indexer) with one
@@ -24,7 +24,7 @@ export function parseJsonc(raw) {
24
24
  continue;
25
25
  }
26
26
  if (char === "/" && next === "/") {
27
- while (index < raw.length && raw[index] !== "\n")
27
+ while (index < raw.length && raw[index] !== "\n" && raw[index] !== "\r")
28
28
  index += 1;
29
29
  withoutComments += "\n";
30
30
  continue;
@@ -33,7 +33,12 @@ export function parseJsonc(raw) {
33
33
  index += 2;
34
34
  while (index < raw.length && !(raw[index] === "*" && raw[index + 1] === "/"))
35
35
  index += 1;
36
+ if (index >= raw.length)
37
+ throw new SyntaxError("unterminated JSONC block comment");
36
38
  index += 1;
39
+ // A comment separates tokens. Removing it outright would silently turn
40
+ // invalid input such as 1/* comment */2 into the valid number 12.
41
+ withoutComments += " ";
37
42
  continue;
38
43
  }
39
44
  withoutComments += char;
@@ -62,8 +67,13 @@ export function parseJsonc(raw) {
62
67
  let cursor = index + 1;
63
68
  while (cursor < withoutComments.length && /\s/.test(withoutComments[cursor]))
64
69
  cursor += 1;
65
- if (withoutComments[cursor] === "}" || withoutComments[cursor] === "]")
66
- continue;
70
+ if (withoutComments[cursor] === "}" || withoutComments[cursor] === "]") {
71
+ const previous = normalized.trimEnd().at(-1);
72
+ // A trailing comma must follow a value, never an empty collection or
73
+ // another comma. Keep malformed input intact for JSON.parse to reject.
74
+ if (previous && !"[{,:".includes(previous))
75
+ continue;
76
+ }
67
77
  }
68
78
  normalized += char;
69
79
  }
@@ -0,0 +1,7 @@
1
+ /** Containment for store artifacts outside JsonStore's entity registry (ledgers,
2
+ * policy proofs, and local audit logs). The explicitly selected store's parent
3
+ * may have a platform alias, but no component inside that store may be a link. */
4
+ export declare function storeArtifactPath(hunchDir: string, ...parts: string[]): string;
5
+ /** A missing artifact is distinct from an unsafe/unreadable artifact. Reuse the
6
+ * scanner's bounded descriptor read; policy and ledger corruption must fail visibly. */
7
+ export declare function readStoreArtifact(hunchDir: string, parts: string[], maxBytes?: number): string | null;
@@ -0,0 +1,62 @@
1
+ import { lstatSync, realpathSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { createRepoFileReader } from "./safeRepoFile.js";
4
+ /** Containment for store artifacts outside JsonStore's entity registry (ledgers,
5
+ * policy proofs, and local audit logs). The explicitly selected store's parent
6
+ * may have a platform alias, but no component inside that store may be a link. */
7
+ export function storeArtifactPath(hunchDir, ...parts) {
8
+ let path = resolve(hunchDir);
9
+ let parent;
10
+ try {
11
+ parent = realpathSync(dirname(path));
12
+ }
13
+ catch (error) {
14
+ if (error.code !== "ENOENT")
15
+ throw error;
16
+ parent = dirname(path);
17
+ }
18
+ let expected = join(parent, basename(path));
19
+ const check = (directory) => {
20
+ try {
21
+ const stat = lstatSync(path);
22
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() && !stat.isDirectory())
23
+ || (stat.isFile() && stat.nlink !== 1) || realpathSync(path) !== expected) {
24
+ throw new Error(`unsafe store artifact path ${path}: symlinks, hard links and special files are refused`);
25
+ }
26
+ }
27
+ catch (error) {
28
+ if (error.code !== "ENOENT")
29
+ throw error;
30
+ }
31
+ };
32
+ check(true);
33
+ for (let index = 0; index < parts.length; index++) {
34
+ const part = parts[index];
35
+ if (!/^[A-Za-z0-9._-]+$/.test(part) || part === "." || part === "..")
36
+ throw new Error("unsafe store artifact path component");
37
+ path = join(path, part);
38
+ expected = join(expected, part);
39
+ check(index < parts.length - 1);
40
+ }
41
+ return path;
42
+ }
43
+ /** A missing artifact is distinct from an unsafe/unreadable artifact. Reuse the
44
+ * scanner's bounded descriptor read; policy and ledger corruption must fail visibly. */
45
+ export function readStoreArtifact(hunchDir, parts, maxBytes = 256 * 1024 * 1024) {
46
+ const file = storeArtifactPath(hunchDir, ...parts);
47
+ try {
48
+ if (!lstatSync(file).isFile())
49
+ throw new Error(`unsafe store artifact path ${file}: expected an ordinary file`);
50
+ }
51
+ catch (error) {
52
+ if (error.code === "ENOENT")
53
+ return null;
54
+ throw error;
55
+ }
56
+ const text = createRepoFileReader(dirname(resolve(hunchDir)), { maxBytes })(file);
57
+ if (text === null)
58
+ throw new Error(`unsafe or unreadable store artifact ${file}`);
59
+ storeArtifactPath(hunchDir, ...parts);
60
+ return text;
61
+ }
62
+ //# sourceMappingURL=storeArtifact.js.map
@@ -222,6 +222,55 @@ export declare function recordReportConformance(root: string, taskId: string, co
222
222
  export declare function recordReportCheck(root: string, taskId: string, check: ReportCheck): string;
223
223
  export declare function beginReportCheck(root: string, taskId: string, label: string): string;
224
224
  export declare function finishReportTask(root: string, taskId: string, state?: "completed" | "interrupted"): ReportTask;
225
+ /** A report with no observation of any kind. Presentation surfaces may stay
226
+ * silent for it; the task row itself is retained so "never touched Hunch" is
227
+ * still countable (hunch report / the VS Code view / task list). */
228
+ export declare function isEmptyTaskReport(report: Pick<TaskReport, "deliveries" | "claims" | "checks" | "conformance" | "saves" | "refusals">): boolean;
229
+ export interface TaskSummary {
230
+ task: ReportTask;
231
+ deliveries: number;
232
+ lessons: number;
233
+ claims: number;
234
+ saves: number;
235
+ refusals: number;
236
+ /** Standing of the last recorded check, or null when none ran. */
237
+ check: {
238
+ label: string;
239
+ state: "passed" | "failed" | "timed out" | "cancelled";
240
+ current: boolean;
241
+ } | null;
242
+ /** Any delivered rule evaluated as violated on the changed files. */
243
+ violated: boolean;
244
+ coverage: TaskReport["coverage"];
245
+ empty: boolean;
246
+ /** Generated evidence view, when one has been written for this task. */
247
+ report_html: string | null;
248
+ /** Set when the observation ledger could not be read for this task. */
249
+ error: string | null;
250
+ }
251
+ /** One bounded summary per recent task for status lines and host views; the
252
+ * card and evidence view remain the authoritative renderings. */
253
+ export declare function summarizeTaskReport(root: string, taskId: string, currentSnapshot?: string | null): TaskSummary;
254
+ export declare function listTaskSummaries(root: string, limit?: number, currentSnapshot?: string | null): TaskSummary[];
255
+ /** One line for a terminal status line. Empty string when nothing was observed
256
+ * for the task, so a bare prompt shows no Hunch noise at all. */
257
+ export declare function renderTaskStatusLine(summary: TaskSummary | null): string;
258
+ export interface TaskReportStats {
259
+ since: string;
260
+ tasks: number;
261
+ completed: number;
262
+ with_delivery: number;
263
+ with_check: number;
264
+ with_claim: number;
265
+ with_save: number;
266
+ with_refusal: number;
267
+ empty: number;
268
+ /** with_delivery / tasks, the adherence number worth watching; null when no tasks. */
269
+ delivery_rate: number | null;
270
+ }
271
+ /** Adherence over a window: how many prompts Hunch actually reached. Counts
272
+ * come from the ledger, never from agent claims; a claim is counted as a claim. */
273
+ export declare function taskReportStats(root: string, days?: number): TaskReportStats;
225
274
  export declare function listReportTasks(root: string): ReportTask[];
226
275
  export declare function reportActivity(root: string): string;
227
276
  /** Exact task deletion is a user-invoked operation, never a memory deletion. */
@@ -368,6 +368,105 @@ export function finishReportTask(root, taskId, state = "completed") {
368
368
  return finished;
369
369
  }));
370
370
  }
371
+ /** A report with no observation of any kind. Presentation surfaces may stay
372
+ * silent for it; the task row itself is retained so "never touched Hunch" is
373
+ * still countable (hunch report / the VS Code view / task list). */
374
+ export function isEmptyTaskReport(report) {
375
+ return !report.deliveries.length && !report.claims.length && !report.checks.length && !report.conformance.length && !report.saves.length && !report.refusals.length;
376
+ }
377
+ /** One bounded summary per recent task for status lines and host views; the
378
+ * card and evidence view remain the authoritative renderings. */
379
+ export function summarizeTaskReport(root, taskId, currentSnapshot = null) {
380
+ const html = join(root, ".hunch-cache", "reports", `${taskId}.html`);
381
+ try {
382
+ const report = readTaskReport(root, taskId, currentSnapshot);
383
+ const last = report.checks.at(-1);
384
+ const standing = new Map(report.conformance.map(r => [`${r.kind}:${r.record_id}:${r.content_hash}`, r]));
385
+ return {
386
+ task: report.task,
387
+ deliveries: report.deliveries.length,
388
+ lessons: new Set(report.deliveries.flatMap(d => d.records).map(r => `${r.kind}:${r.record_id}`)).size,
389
+ claims: report.claims.length,
390
+ saves: report.saves.length,
391
+ refusals: report.refusals.length,
392
+ check: last ? { label: last.label, state: last.cancelled ? "cancelled" : last.timed_out ? "timed out" : last.exit_code === 0 ? "passed" : "failed", current: last.current } : null,
393
+ violated: [...standing.values()].some(r => r.outcome === "violated"),
394
+ coverage: report.coverage,
395
+ empty: isEmptyTaskReport(report),
396
+ report_html: existsSync(html) ? html : null,
397
+ error: null,
398
+ };
399
+ }
400
+ catch (e) {
401
+ return taskDb(root, db => {
402
+ const row = db.prepare("SELECT body FROM report_tasks WHERE task_id = ?").get(taskId);
403
+ if (!row)
404
+ throw e;
405
+ return { task: TaskSchema.parse(JSON.parse(row.body)), deliveries: 0, lessons: 0, claims: 0, saves: 0, refusals: 0, check: null, violated: false, coverage: "no-delivery-observed", empty: true, report_html: existsSync(html) ? html : null, error: e.message };
406
+ });
407
+ }
408
+ }
409
+ export function listTaskSummaries(root, limit = 30, currentSnapshot = null) {
410
+ if (!existsSync(join(root, ".hunch-cache", "served.db")))
411
+ return [];
412
+ return listReportTasks(root).slice(0, Math.max(1, Math.min(limit, 30))).map(task => summarizeTaskReport(root, task.task_id, currentSnapshot));
413
+ }
414
+ /** One line for a terminal status line. Empty string when nothing was observed
415
+ * for the task, so a bare prompt shows no Hunch noise at all. */
416
+ export function renderTaskStatusLine(summary) {
417
+ if (!summary || summary.empty)
418
+ return "";
419
+ const parts = [`Hunch`];
420
+ parts.push(summary.lessons ? `${summary.lessons} lesson${summary.lessons === 1 ? "" : "s"} recalled` : summary.deliveries ? "memory delivered" : "no delivery");
421
+ if (summary.violated)
422
+ parts.push("rule violated");
423
+ else if (summary.claims)
424
+ parts.push(`${summary.claims} applied`);
425
+ if (summary.saves)
426
+ parts.push(`${summary.saves} saved`);
427
+ if (summary.refusals)
428
+ parts.push("edit denied");
429
+ if (summary.check)
430
+ parts.push(`${summary.check.label}: ${summary.check.state}${summary.check.current ? "" : " (source changed)"}`);
431
+ else
432
+ parts.push("no check recorded");
433
+ return parts.join(" · ");
434
+ }
435
+ /** Adherence over a window: how many prompts Hunch actually reached. Counts
436
+ * come from the ledger, never from agent claims; a claim is counted as a claim. */
437
+ export function taskReportStats(root, days = 7) {
438
+ const since = new Date(Date.now() - Math.max(1, days) * 86_400_000).toISOString();
439
+ const empty = { since, tasks: 0, completed: 0, with_delivery: 0, with_check: 0, with_claim: 0, with_save: 0, with_refusal: 0, empty: 0, delivery_rate: null };
440
+ if (!existsSync(join(root, ".hunch-cache", "served.db")))
441
+ return empty;
442
+ 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)
444
+ .map(r => TaskSchema.parse(JSON.parse(r.body)));
445
+ if (!tasks.length)
446
+ return empty;
447
+ const kinds = (taskId) => new Set(db.prepare("SELECT DISTINCT kind FROM report_events WHERE task_id = ?").all(taskId).map(r => r.kind));
448
+ const stats = { ...empty, tasks: tasks.length };
449
+ for (const task of tasks) {
450
+ const k = kinds(task.task_id);
451
+ if (task.state === "completed")
452
+ stats.completed++;
453
+ if (k.has("delivery"))
454
+ stats.with_delivery++;
455
+ if (k.has("check") || k.has("check-start"))
456
+ stats.with_check++;
457
+ if (k.has("claim"))
458
+ stats.with_claim++;
459
+ if (k.has("save"))
460
+ stats.with_save++;
461
+ if (k.has("refusal"))
462
+ stats.with_refusal++;
463
+ if (!k.size)
464
+ stats.empty++;
465
+ }
466
+ stats.delivery_rate = stats.with_delivery / stats.tasks;
467
+ return stats;
468
+ });
469
+ }
371
470
  export function listReportTasks(root) {
372
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))));
373
472
  }
@@ -1,10 +1,16 @@
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. */
4
+ export declare function promptTaskId(root: string, sessionId: string, promptId: string, agentId?: string | null, provider?: HookProvider): string;
2
5
  export declare function hookReportTaskId(root: string, provider: HookProvider, event: HunchHookInput): string | null;
3
6
  /** Every prompt receives its exact ID, even when ambient reminders were deduped.
4
7
  * No raw prompt, host session identifier, or transcript is retained. */
5
8
  export declare function startHookReport(root: string, provider: HookProvider, event: HunchHookInput): string | null;
6
9
  /** A presentation notice never denies Stop or injects another model turn. Stop
7
- * can precede another hook's continuation, so it does not close an open task. */
10
+ * can precede another hook's continuation, so it does not close an open task.
11
+ * A prompt with no observation at all prints nothing: the empty task row stays
12
+ * in the ledger (hunch task list, the VS Code Contribution view) so "never
13
+ * touched Hunch" remains countable without a five-line notice per prompt. */
8
14
  export declare function stopHookReport(root: string, provider: HookProvider, event: HunchHookInput): {
9
15
  systemMessage: string;
10
16
  } | null;
@@ -3,11 +3,19 @@
3
3
  import { realpathSync } from "node:fs";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { findRoot } from "./paths.js";
6
- import { readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, startReportTask } from "./taskReport.js";
6
+ import { isEmptyTaskReport, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, startReportTask } from "./taskReport.js";
7
7
  import { reportSourceSnapshot } from "./taskReportEvidence.js";
8
8
  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. */
11
+ export function promptTaskId(root, sessionId, promptId, agentId = null, provider = "claude") {
12
+ return `htask_${reportHash([realpathSync(root), provider, sessionId, promptId, agentId]).slice(7, 31)}`;
13
+ }
14
+ /** Hosts whose hooks deliver a native per-prompt identity (Claude Code's
15
+ * prompt_id, Codex's turn_id). Others get no task from a hook. */
16
+ const NATIVE_PROMPT_HOSTS = new Set(["claude", "codex"]);
9
17
  function identity(root, provider, event) {
10
- if (provider !== "claude" || !event.cwd || realpathSync(findRoot(event.cwd)) !== realpathSync(root))
18
+ if (!NATIVE_PROMPT_HOSTS.has(provider) || !event.cwd || realpathSync(findRoot(event.cwd)) !== realpathSync(root))
11
19
  return null;
12
20
  for (const value of [event.session_id, event.prompt_id, event.agent_id]) {
13
21
  if (value !== undefined && (!value.length || value.length > 1024 || /[\u0000-\u001f\u007f]/.test(value)))
@@ -17,7 +25,7 @@ function identity(root, provider, event) {
17
25
  return null;
18
26
  if (!event.prompt_id)
19
27
  return "legacy";
20
- return `htask_${reportHash([realpathSync(root), provider, event.session_id, event.prompt_id, event.agent_id ?? null]).slice(7, 31)}`;
28
+ return promptTaskId(root, event.session_id, event.prompt_id, event.agent_id ?? null, provider);
21
29
  }
22
30
  export function hookReportTaskId(root, provider, event) {
23
31
  try {
@@ -38,7 +46,10 @@ export function startHookReport(root, provider, event) {
38
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.`;
39
47
  }
40
48
  /** A presentation notice never denies Stop or injects another model turn. Stop
41
- * can precede another hook's continuation, so it does not close an open task. */
49
+ * can precede another hook's continuation, so it does not close an open task.
50
+ * A prompt with no observation at all prints nothing: the empty task row stays
51
+ * in the ledger (hunch task list, the VS Code Contribution view) so "never
52
+ * touched Hunch" remains countable without a five-line notice per prompt. */
42
53
  export function stopHookReport(root, provider, event) {
43
54
  if (!reportPresentationEnabled(root))
44
55
  return null;
@@ -46,9 +57,11 @@ export function stopHookReport(root, provider, event) {
46
57
  if (!id)
47
58
  return null;
48
59
  if (id === "legacy")
49
- return { systemMessage: "Hunch hook active. This Claude version does not provide an exact prompt identifier, so contribution for this response is unverified. Explicit task reports remain available with hunch report." };
60
+ return { systemMessage: "Hunch hook active. This host version does not provide an exact prompt identifier, so contribution for this response is unverified. Explicit task reports remain available with hunch report." };
50
61
  try {
51
62
  const report = readTaskReport(root, id, reportSourceSnapshot(root).hash);
63
+ if (isEmptyTaskReport(report))
64
+ return null;
52
65
  let card = renderTaskReport(report);
53
66
  try {
54
67
  const file = writeTaskReportHtml(root, id);
@@ -146,12 +146,29 @@ export function healClaudeConfigCaseSplit(opts = {}) {
146
146
  if (keys.length < 2)
147
147
  continue; // no casing split for this directory
148
148
  keys.sort(); // deterministic first-wins union
149
- const blocks = keys.map((k) => (isPlainObject(projects[k]) ? projects[k] : {}));
149
+ // A root object can still contain malformed project blocks. Do not replace a
150
+ // user's scalar/array block, or normalize malformed nested MCP/list fields,
151
+ // merely because another drive-letter casing is valid.
152
+ for (const key of keys) {
153
+ const block = projects[key];
154
+ if (!isPlainObject(block)) {
155
+ throw new Error(`refusing to modify ${file}: project ${key} is not an object; fix it, then re-run.`);
156
+ }
157
+ const mcp = block.mcpServers;
158
+ if (mcp !== undefined && !isPlainObject(mcp)) {
159
+ throw new Error(`refusing to modify ${file}: project ${key}.mcpServers must be an object; fix it, then re-run.`);
160
+ }
161
+ for (const listKey of ["enabledMcpjsonServers", "disabledMcpjsonServers"]) {
162
+ const list = block[listKey];
163
+ if (list !== undefined && (!Array.isArray(list) || !list.every((value) => typeof value === "string"))) {
164
+ throw new Error(`refusing to modify ${file}: project ${key}.${listKey} must be a string array; fix it, then re-run.`);
165
+ }
166
+ }
167
+ }
168
+ const blocks = keys.map((k) => projects[k]);
150
169
  const u = unionConfig(blocks);
151
170
  let groupChanged = false;
152
171
  for (const k of keys) {
153
- if (!isPlainObject(projects[k]))
154
- projects[k] = {};
155
172
  if (applyUnion(projects[k], u))
156
173
  groupChanged = true;
157
174
  }
@@ -47,7 +47,7 @@ export function renderHunchSection(store, root) {
47
47
  lines.push("**Consult Hunch via the `hunch_*` MCP tools — pick by MOMENT, not from memory:**");
48
48
  lines.push("");
49
49
  lines.push("**Orient (session/task start):**");
50
- lines.push("- For a new user task, call `hunch_task(action: \"start\", title: <short task title>)` once and retain its `task_id`. If a native prompt hook already supplied a task ID, reuse its exact start arguments instead of creating another task; each new native prompt has its own ID. Otherwise reuse the ID for follow-up work on the same task; never borrow another task's ID. This is task bookkeeping; `hunch_context` remains the first memory lookup. If reporting fails, continue the work and disclose the gap.");
50
+ lines.push("- For a new user task, call `hunch_task(action: \"start\", title: <short task title>)` once and retain its `task_id`. Claude Code's prompt hook supplies a task ID natively — reuse its exact start arguments instead of creating another task (each new prompt has its own ID). Codex supplies one the same way once its `.codex/hooks.json` is trusted (`/hooks`). Hosts without prompt hooks (Windsurf, Cursor) never receive one: start the task yourself. Reuse the ID for follow-up work on the same task; never borrow another task's ID. This is task bookkeeping; `hunch_context` remains the first memory lookup. If reporting fails, continue the work and disclose the gap.");
51
51
  lines.push("- When the user asks to **update Hunch**, run `hunch update` from this repository root. It updates to the latest release and repairs all configured harness pins. Use `hunch update --global` to also update a global CLI alongside a repository dependency; reconnect active MCP sessions afterward.");
52
52
  lines.push("- `hunch_context(target, task_id)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST** for memory. Include the current task ID on each context call so its contribution is inspectable.");
53
53
  lines.push("- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol — orient from the graph, not grep rounds.");
@@ -10,9 +10,9 @@ export declare const HARNESSES: {
10
10
  };
11
11
  readonly codex: {
12
12
  readonly mcp: ".codex/config.toml";
13
- readonly hooks: "";
14
- readonly key: "";
15
- readonly events: readonly [];
13
+ readonly hooks: ".codex/hooks.json";
14
+ readonly key: "hooks";
15
+ readonly events: readonly ["SessionStart", "PreToolUse", "PostToolUse", "PreCompact"];
16
16
  };
17
17
  readonly cursor: {
18
18
  readonly mcp: ".cursor/mcp.json";
@@ -58,6 +58,11 @@ export interface IntegrationHealth {
58
58
  scope: "repository-config";
59
59
  issues: HealthIssue[];
60
60
  harnesses: HarnessHealth[];
61
+ /** Every exact Hunch pin found in repository launch config, once per file+version. */
62
+ pins: Array<{
63
+ file: string;
64
+ version: string;
65
+ }>;
61
66
  }
62
67
  export declare function readLauncher(root: string, harness: Harness): {
63
68
  command: string;
@@ -65,9 +70,18 @@ export declare function readLauncher(root: string, harness: Harness): {
65
70
  customEnvironment: boolean;
66
71
  };
67
72
  export declare function inspectIntegrations(root: string, selected?: Harness): IntegrationHealth;
73
+ /** Harness launch files git ignores: this machine's config, never the tag's. A
74
+ * release cut may keep these at the last published version (see
75
+ * tooling/sync-version-pins.mjs) so hooks and MCP never point at a version npm
76
+ * cannot serve. Unknown git state yields [] — callers then treat nothing as local. */
77
+ export declare function machineLocalIntegrationFiles(root: string): string[];
68
78
  /** Repair only exact published pins. Preserve formatting and all other values.
69
- * Preflight every affected file before writing any; reject malformed JSON/TOML. */
70
- export declare function repairIntegrationPins(root: string): string[];
79
+ * Preflight every affected file before writing any; reject malformed JSON/TOML.
80
+ * `skip` leaves a file untouched (used to keep machine-local pins on a version
81
+ * npm can actually serve while a release is still publishing). */
82
+ export declare function repairIntegrationPins(root: string, opts?: {
83
+ skip?: (file: string) => boolean;
84
+ }): string[];
71
85
  export declare function integrationHealthFails(report: IntegrationHealth, required?: readonly Capability[]): boolean;
72
86
  export declare function formatIntegrationHealth(report: IntegrationHealth): string;
73
87
  /** Bounded session warning; diagnostics must never break hook execution. */
@@ -1,6 +1,7 @@
1
1
  /** Repository integration checks. Configuration is evidence of wiring, never
2
2
  * evidence that a host delivered context or enforced a decision. */
3
3
  import { existsSync, readFileSync, lstatSync } from "node:fs";
4
+ import { spawnSync } from "node:child_process";
4
5
  import { join, resolve } from "node:path";
5
6
  import { parse as parseToml } from "smol-toml";
6
7
  import { parseJsonc } from "../core/jsonc.js";
@@ -20,7 +21,7 @@ const OBSERVATION_FRESH_MS = 30 * 86_400_000;
20
21
  export const CAPABILITIES = ["mcp", "context", "edit-blocking", "failure-capture", "compaction"];
21
22
  export const HARNESSES = {
22
23
  claude: { mcp: ".mcp.json", hooks: ".claude/settings.json", key: "mcpServers", events: ["SessionStart", "PreToolUse", "PostToolUseFailure", "PreCompact"] },
23
- codex: { mcp: ".codex/config.toml", hooks: "", key: "", events: [] },
24
+ codex: { mcp: ".codex/config.toml", hooks: ".codex/hooks.json", key: "hooks", events: ["SessionStart", "PreToolUse", "PostToolUse", "PreCompact"] },
24
25
  cursor: { mcp: ".cursor/mcp.json", hooks: ".cursor/hooks.json", key: "mcpServers", events: ["sessionStart", "preToolUse", "postToolUse", ""] },
25
26
  vscode: { mcp: ".vscode/mcp.json", hooks: ".github/hooks/hunch.json", key: "servers", events: ["SessionStart", "PreToolUse", "PostToolUse", ""] },
26
27
  windsurf: { mcp: ".windsurf/mcp_config.json", hooks: ".windsurf/hooks.json", key: "mcpServers", events: ["", "pre_write_code", "post_run_command", ""] },
@@ -51,7 +52,7 @@ function hookCommands(value) {
51
52
  if (obj.enabled === false || (obj.type !== undefined && obj.type !== "command"))
52
53
  return [];
53
54
  const command = typeof obj.command === "string" ? obj.command : "";
54
- const own = /(?:@davesheffer\/hunch|[\\/]index\.(?:js|ts))/.test(command)
55
+ const own = /(?:@davesheffer\/hunch|(?:dist|src)[\\/]+cli[\\/]+index\.(?:js|ts))/.test(command)
55
56
  && /\s"?hook"?(?:\s+"?--provider"?\s+"?[a-z]+"?)?\s*$/.test(command);
56
57
  return [...(own ? [command] : []), ...(obj.hooks ? hookCommands(obj.hooks) : [])];
57
58
  }
@@ -102,7 +103,7 @@ function expectedVersion(root) {
102
103
  return version;
103
104
  }
104
105
  export function inspectIntegrations(root, selected) {
105
- const report = { schema: "hunch.integration-health/1", expectedVersion: HUNCH_VERSION, scope: "repository-config", issues: [], harnesses: [] };
106
+ const report = { schema: "hunch.integration-health/1", expectedVersion: HUNCH_VERSION, scope: "repository-config", issues: [], harnesses: [], pins: [] };
106
107
  try {
107
108
  report.expectedVersion = expectedVersion(root);
108
109
  }
@@ -115,7 +116,9 @@ export function inspectIntegrations(root, selected) {
115
116
  const pins = [...value.matchAll(pinPattern)];
116
117
  if (value.includes("@davesheffer/hunch") && !pins.length)
117
118
  report.issues.push({ file, code: "unpinned-package", detail: "Hunch npm launcher has no exact version; run hunch init with the intended version" });
118
- for (const [, version] of pins) {
119
+ for (const [, version = ""] of pins) {
120
+ if (!report.pins.some(p => p.file === file && p.version === version))
121
+ report.pins.push({ file, version });
119
122
  if (version !== report.expectedVersion)
120
123
  report.issues.push({ file, code: "version-drift", detail: `Hunch ${version} differs from expected ${report.expectedVersion}; run hunch integrations repair-pins` });
121
124
  }
@@ -146,7 +149,11 @@ export function inspectIntegrations(root, selected) {
146
149
  }
147
150
  let events = {};
148
151
  let disabled = false;
149
- if (spec.hooks) {
152
+ // A hooks file that was never written is a coverage gap (the adapter is
153
+ // not installed), not configuration drift: report it, never fail on it.
154
+ // `--require` still refuses, because nothing unverified counts.
155
+ const hooksAbsent = !!spec.hooks && !existsSync(join(root, spec.hooks));
156
+ if (spec.hooks && !hooksAbsent) {
150
157
  try {
151
158
  const config = object(parseJsonc(readFileSync(join(root, spec.hooks), "utf8")));
152
159
  disabled = config.disableAllHooks === true;
@@ -164,6 +171,9 @@ export function inspectIntegrations(root, selected) {
164
171
  status.status = capability === "context" ? "advisory-only" : "unsupported";
165
172
  status.detail = capability === "context" ? "Hunch relies on instructions and voluntary MCP calls on this adapter" : "No Hunch lifecycle adapter for this capability";
166
173
  }
174
+ else if (hooksAbsent) {
175
+ status.detail = `No ${spec.hooks}; run hunch init to install this host's lifecycle hooks`;
176
+ }
167
177
  else if (disabled || firmness === "off" || ((capability === "failure-capture") && process.env.HUNCH_PIPELINE === "0")) {
168
178
  status.status = "unsupported";
169
179
  status.detail = "Disabled by local hook settings, firmness, or HUNCH_PIPELINE";
@@ -201,15 +211,30 @@ export function inspectIntegrations(root, selected) {
201
211
  report.issues.push({ file: ".", code: "no-integrations", detail: "No repository integrations found; global and managed host settings are not inspected" });
202
212
  return report;
203
213
  }
214
+ /** Harness launch files git ignores: this machine's config, never the tag's. A
215
+ * release cut may keep these at the last published version (see
216
+ * tooling/sync-version-pins.mjs) so hooks and MCP never point at a version npm
217
+ * cannot serve. Unknown git state yields [] — callers then treat nothing as local. */
218
+ export function machineLocalIntegrationFiles(root) {
219
+ const files = Object.values(HARNESSES).flatMap(s => [s.mcp, s.hooks]).filter(f => f && existsSync(join(root, f)));
220
+ if (!files.length)
221
+ return [];
222
+ const r = spawnSync("git", ["check-ignore", "--", ...files], { cwd: root, encoding: "utf8", windowsHide: true });
223
+ if (r.error || (r.status !== 0 && r.status !== 1))
224
+ return [];
225
+ return (r.stdout ?? "").split(/\r?\n/).map(l => l.trim()).filter(Boolean);
226
+ }
204
227
  /** Repair only exact published pins. Preserve formatting and all other values.
205
- * Preflight every affected file before writing any; reject malformed JSON/TOML. */
206
- export function repairIntegrationPins(root) {
228
+ * Preflight every affected file before writing any; reject malformed JSON/TOML.
229
+ * `skip` leaves a file untouched (used to keep machine-local pins on a version
230
+ * npm can actually serve while a release is still publishing). */
231
+ export function repairIntegrationPins(root, opts = {}) {
207
232
  const version = expectedVersion(root);
208
233
  const pending = [];
209
234
  for (const [name, spec] of Object.entries(HARNESSES)) {
210
235
  for (const file of [spec.mcp, spec.hooks].filter(Boolean)) {
211
236
  const path = join(root, file);
212
- if (!existsSync(path))
237
+ if (!existsSync(path) || opts.skip?.(file))
213
238
  continue;
214
239
  // Never follow a config symlink or symlinked parent into another project.
215
240
  let current = resolve(root);
@@ -225,15 +250,15 @@ export function repairIntegrationPins(root) {
225
250
  return `@davesheffer/hunch@${version}`;
226
251
  });
227
252
  let after;
228
- if (name === "codex") {
253
+ if (name === "codex" && file === spec.mcp) {
229
254
  readLauncher(root, "codex");
230
255
  const block = codexBlock(before);
231
256
  const table = object(object(parseToml(block).mcp_servers).hunch);
232
- if (Object.keys(table).some(key => !["command", "args"].includes(key)))
257
+ if (Object.keys(table).some(key => !["command", "args", "startup_timeout_sec"].includes(key)))
233
258
  throw new Error(`custom managed settings require manual pin repair: ${file}`);
234
259
  // Replace only the canonical args line, never comments or another table.
235
260
  const lines = block.split("\n");
236
- if (lines.some(line => line.trim() && !line.trim().startsWith("#") && !/^\s*(?:\[mcp_servers\.hunch\]|command\s*=|args\s*=)/.test(line)))
261
+ if (lines.some(line => line.trim() && !line.trim().startsWith("#") && !/^\s*(?:\[mcp_servers\.hunch\]|command\s*=|args\s*=|startup_timeout_sec\s*=)/.test(line)))
237
262
  throw new Error(`custom managed TOML requires manual pin repair: ${file}`);
238
263
  const next = lines.map(line => /^\s*args\s*=/.test(line) ? replace(line.split("#")[0]) + (line.includes("#") ? `#${line.split("#").slice(1).join("#")}` : "") : line).join("\n");
239
264
  after = before.replace(block, next);