@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.
- package/README.md +9 -1
- package/dist/cli/index.js +50 -6
- package/dist/cli/invocation.d.ts +8 -0
- package/dist/cli/invocation.js +17 -10
- package/dist/cli/taskReport.js +52 -4
- package/dist/cli/update.js +5 -5
- package/dist/client/state.d.ts +4 -4
- package/dist/constitution/schema.d.ts +12 -12
- package/dist/core/stateContract.d.ts +3 -3
- package/dist/core/taskRecord.d.ts +39 -0
- package/dist/core/taskRecord.js +185 -0
- package/dist/core/taskReport.d.ts +8 -1
- package/dist/core/taskReport.js +28 -14
- package/dist/core/taskReportEvidence.js +2 -1
- package/dist/core/taskReportHook.d.ts +18 -3
- package/dist/core/taskReportHook.js +77 -8
- package/dist/core/taskReportPaths.d.ts +6 -0
- package/dist/core/taskReportPaths.js +13 -0
- package/dist/core/types.d.ts +176 -1
- package/dist/core/types.js +40 -1
- package/dist/core/updatecheck.d.ts +51 -0
- package/dist/core/updatecheck.js +266 -0
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.js +3 -1
- package/dist/integrations/gitignore.js +1 -0
- package/dist/integrations/health.js +27 -2
- package/dist/mcp/server.js +5 -1
- package/dist/mcp/taskReportTools.d.ts +4 -4
- package/dist/mcp/taskReportTools.js +32 -3
- package/dist/store/hunchStore.d.ts +5 -1
- package/dist/store/hunchStore.js +18 -0
- package/dist/taskReports.d.ts +1 -1
- package/dist/taskReports.js +16 -4
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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. */
|
package/dist/core/taskReport.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
|
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([
|
|
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 (!
|
|
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
|
-
|
|
46
|
-
|
|
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) {
|