@davesheffer/hunch 1.37.1 → 1.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +24 -1
- package/dist/cli/taskReport.js +1 -1
- package/dist/core/taskRecord.d.ts +11 -2
- package/dist/core/taskRecord.js +97 -42
- package/dist/core/taskReport.d.ts +65 -3
- package/dist/core/taskReport.js +154 -18
- package/dist/core/taskReportEvidence.d.ts +4 -1
- package/dist/core/taskReportEvidence.js +8 -5
- package/dist/core/taskReportHook.d.ts +24 -2
- package/dist/core/taskReportHook.js +104 -5
- package/dist/core/taskTouched.d.ts +9 -0
- package/dist/core/taskTouched.js +97 -0
- package/dist/mcp/taskReportTools.d.ts +17 -0
- package/dist/mcp/taskReportTools.js +13 -5
- package/dist/taskReports.d.ts +8 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -88,7 +88,8 @@ import { recordServed, servedSummary } from "../core/served.js";
|
|
|
88
88
|
import { recordTaskDelivery, reportActivity, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
|
|
89
89
|
import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
|
|
90
90
|
import { renderRecalledLine } from "../core/taskReportRender.js";
|
|
91
|
-
import { hookReportTaskId, nativeHookCwd, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
|
|
91
|
+
import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
|
|
92
|
+
import { persistTaskRecord } from "../core/taskRecord.js";
|
|
92
93
|
import { recordHookObservation } from "../core/hookObservations.js";
|
|
93
94
|
import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
|
|
94
95
|
import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, unverifiedNag, } from "../core/pipeline.js";
|
|
@@ -4548,6 +4549,17 @@ program
|
|
|
4548
4549
|
return;
|
|
4549
4550
|
}
|
|
4550
4551
|
}
|
|
4552
|
+
// The turn is over: close the prompt's task and keep its record, whether
|
|
4553
|
+
// or not the agent called finish. Fail-open: the card below still renders.
|
|
4554
|
+
try {
|
|
4555
|
+
const closed = closeHookTask(root, provider, evt);
|
|
4556
|
+
if (closed.length) {
|
|
4557
|
+
store ??= new HunchStore(paths);
|
|
4558
|
+
for (const id of closed)
|
|
4559
|
+
persistTaskRecord(root, store, id);
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
4562
|
+
catch { /* the ledger and the card remain authoritative; the next finish retries */ }
|
|
4551
4563
|
const report = stopHookReport(root, provider, evt);
|
|
4552
4564
|
if (report)
|
|
4553
4565
|
console.log(JSON.stringify(report));
|
|
@@ -4575,6 +4587,17 @@ program
|
|
|
4575
4587
|
}
|
|
4576
4588
|
}
|
|
4577
4589
|
catch { /* passive reporting remains fail-open */ }
|
|
4590
|
+
// A task an earlier prompt of this session left open (interrupted before
|
|
4591
|
+
// its Stop) is over now: close it and keep its record.
|
|
4592
|
+
try {
|
|
4593
|
+
const settled = settleHookSession(root, provider, evt, { keepId: hookReportTaskId(root, provider, evt), keepNewest: true });
|
|
4594
|
+
if (settled.length) {
|
|
4595
|
+
store ??= new HunchStore(paths);
|
|
4596
|
+
for (const id of settled)
|
|
4597
|
+
persistTaskRecord(root, store, id);
|
|
4598
|
+
}
|
|
4599
|
+
}
|
|
4600
|
+
catch { /* the next Stop or prompt retries */ }
|
|
4578
4601
|
// Pipeline turn bookkeeping (fresh block budget) + the one nag that must
|
|
4579
4602
|
// repeat: edits from an earlier turn still unverified.
|
|
4580
4603
|
if (evt.session_id && pipelineEnabled()) {
|
package/dist/cli/taskReport.js
CHANGED
|
@@ -90,7 +90,7 @@ export function registerTaskReportCommands(program, openStore) {
|
|
|
90
90
|
return;
|
|
91
91
|
}
|
|
92
92
|
for (const s of summaries)
|
|
93
|
-
console.log(`${s.task.started_at.slice(0, 16).replace("T", " ")} ${s.task.task_id} ${s.task.state.padEnd(11)} ${renderTaskStatusLine(s) || "nothing observed"}${s.durable ? ` [graph: ${s.durable.home}]` : ""}`);
|
|
93
|
+
console.log(`${s.task.started_at.slice(0, 16).replace("T", " ")} ${s.task.task_id} ${s.task.state.padEnd(11)} ${renderTaskStatusLine(s) || "nothing observed"}${s.durable ? ` [graph: ${s.durable.home}${s.task.episode ? ` as ${s.task.episode}` : ""}]` : ""}${s.task.continues && !s.durable ? ` (continues ${s.task.continues})` : ""}`);
|
|
94
94
|
});
|
|
95
95
|
task.command("stats").description("Adherence over a window: how many prompts Hunch reached (delivery), checked, saved, or guarded — from the ledger, never from agent claims")
|
|
96
96
|
.option("--days <days>", "window in days", "7")
|
|
@@ -12,8 +12,17 @@ export declare function taskRecordFlushMode(root: string): "each" | "batch";
|
|
|
12
12
|
/** A delivery target that names code (a path or dotted symbol), not a task
|
|
13
13
|
* phrase like "fix the login redirect". Phrases never become file anchors. */
|
|
14
14
|
export declare function targetLooksLikePath(target: string): boolean;
|
|
15
|
-
/** The durable summary of a finished report, or null when there is nothing to keep.
|
|
16
|
-
|
|
15
|
+
/** The durable summary of a finished report, or null when there is nothing to keep.
|
|
16
|
+
* `touched` adds file anchors the report itself cannot know (git-side work while
|
|
17
|
+
* the task was open); report-derived files come first under the cap. */
|
|
18
|
+
export declare function taskRecordFromReport(report: TaskReport, touched?: readonly string[]): TaskRecord | null;
|
|
19
|
+
/** One record for an EPISODE: a prompt's report and the reports of the prompts
|
|
20
|
+
* that continued it in the same session (oldest first). Observations are the
|
|
21
|
+
* union in order, the id and start are the head's, the title is the first
|
|
22
|
+
* non-generic one, state and finish come from the latest closed member, and
|
|
23
|
+
* the report hash covers every member so a refresh is idempotent. Members
|
|
24
|
+
* still open contribute nothing yet. Null when nothing was observed at all. */
|
|
25
|
+
export declare function taskRecordFromReports(reports: readonly TaskReport[], touched?: readonly string[]): TaskRecord | null;
|
|
17
26
|
/** Where the record belongs. Anything that touched the private overlay — a
|
|
18
27
|
* private save, or a delivered lesson that lives only there — must not be named
|
|
19
28
|
* in a public record; the store's own routing (unified/shared mode) wins first. */
|
package/dist/core/taskRecord.js
CHANGED
|
@@ -14,10 +14,11 @@ import { readFileSync } from "node:fs";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { flushCapture } from "../integrations/sync.js";
|
|
16
16
|
import { hunchPaths } from "./paths.js";
|
|
17
|
-
import { isEmptyTaskReport, readTaskReport, reportHash } from "./taskReport.js";
|
|
17
|
+
import { episodeTasks, isEmptyTaskReport, readTaskReport, reportHash, sessionsOverlap } from "./taskReport.js";
|
|
18
18
|
import { reportSourceSnapshot } from "./taskReportEvidence.js";
|
|
19
19
|
import { ENTITY_KINDS, TaskRecordSchema } from "./types.js";
|
|
20
20
|
import { refreshRankEval } from "./taskRankingMode.js";
|
|
21
|
+
import { gitTouchedFiles } from "./taskTouched.js";
|
|
21
22
|
function localConfig(root) {
|
|
22
23
|
try {
|
|
23
24
|
return JSON.parse(readFileSync(join(root, ".hunch", "local.json"), "utf8"));
|
|
@@ -47,50 +48,76 @@ export function targetLooksLikePath(target) {
|
|
|
47
48
|
return false;
|
|
48
49
|
return /[./\\]/.test(t) && !/^\.+$/.test(t);
|
|
49
50
|
}
|
|
50
|
-
/** The durable summary of a finished report, or null when there is nothing to keep.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
/** The durable summary of a finished report, or null when there is nothing to keep.
|
|
52
|
+
* `touched` adds file anchors the report itself cannot know (git-side work while
|
|
53
|
+
* the task was open); report-derived files come first under the cap. */
|
|
54
|
+
export function taskRecordFromReport(report, touched = []) {
|
|
55
|
+
return taskRecordFromReports([report], touched);
|
|
56
|
+
}
|
|
57
|
+
const GENERIC_TASK_TITLES = new Set(["Assistant task", "Claude task"]);
|
|
58
|
+
const COVERAGE_RANK = { "no-delivery-observed": 0, "no-relevant-memory": 1, delivered: 2 };
|
|
59
|
+
/** One record for an EPISODE: a prompt's report and the reports of the prompts
|
|
60
|
+
* that continued it in the same session (oldest first). Observations are the
|
|
61
|
+
* union in order, the id and start are the head's, the title is the first
|
|
62
|
+
* non-generic one, state and finish come from the latest closed member, and
|
|
63
|
+
* the report hash covers every member so a refresh is idempotent. Members
|
|
64
|
+
* still open contribute nothing yet. Null when nothing was observed at all. */
|
|
65
|
+
export function taskRecordFromReports(reports, touched = []) {
|
|
66
|
+
const closed = reports.filter((r) => r.task.state !== "open" && r.task.finished_at);
|
|
67
|
+
if (!closed.length || closed.every((r) => isEmptyTaskReport(r)))
|
|
56
68
|
return null;
|
|
69
|
+
const head = reports[0].task;
|
|
70
|
+
const last = closed.reduce((a, b) => (b.task.finished_at >= a.task.finished_at ? b : a));
|
|
71
|
+
const title = reports.map((r) => r.task.title).find((t) => !GENERIC_TASK_TITLES.has(t)) ?? head.title;
|
|
57
72
|
const lessons = new Map();
|
|
58
|
-
for (const delivery of report.deliveries) {
|
|
59
|
-
for (const r of delivery.records) {
|
|
60
|
-
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) });
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
73
|
const files = new Set();
|
|
64
|
-
for (const d of report.deliveries)
|
|
65
|
-
if (d.target && targetLooksLikePath(d.target))
|
|
66
|
-
files.add(d.target.trim().replace(/\\/g, "/"));
|
|
67
|
-
for (const c of report.conformance)
|
|
68
|
-
for (const f of c.files)
|
|
69
|
-
files.add(f);
|
|
70
|
-
for (const r of report.refusals)
|
|
71
|
-
files.add(r.target);
|
|
72
74
|
const latestRule = new Map();
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
const applied = [], saved = [], checks = [];
|
|
76
|
+
let refusals = 0, coverage = "no-delivery-observed", sourceSnapshot = null;
|
|
77
|
+
for (const report of closed) {
|
|
78
|
+
for (const delivery of report.deliveries) {
|
|
79
|
+
if (delivery.target && targetLooksLikePath(delivery.target))
|
|
80
|
+
files.add(delivery.target.trim().replace(/\\/g, "/"));
|
|
81
|
+
for (const r of delivery.records)
|
|
82
|
+
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) });
|
|
83
|
+
}
|
|
84
|
+
for (const c of report.conformance) {
|
|
85
|
+
for (const f of c.files)
|
|
86
|
+
files.add(f);
|
|
87
|
+
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 });
|
|
88
|
+
}
|
|
89
|
+
for (const r of report.refusals)
|
|
90
|
+
files.add(r.target);
|
|
91
|
+
applied.push(...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 })));
|
|
92
|
+
saved.push(...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 })));
|
|
93
|
+
checks.push(...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 })));
|
|
94
|
+
refusals += report.refusals.length;
|
|
95
|
+
if (COVERAGE_RANK[report.coverage] > COVERAGE_RANK[coverage])
|
|
96
|
+
coverage = report.coverage;
|
|
97
|
+
const lastCheck = report.checks.at(-1);
|
|
98
|
+
if (lastCheck)
|
|
99
|
+
sourceSnapshot = lastCheck.after_snapshot ?? null;
|
|
75
100
|
}
|
|
76
|
-
const
|
|
101
|
+
const fromReport = [...files].sort();
|
|
102
|
+
const extra = [...new Set(touched.map((f) => f.trim().replace(/\\/g, "/")).filter((f) => f && !files.has(f)))].sort();
|
|
77
103
|
return TaskRecordSchema.parse({
|
|
78
|
-
id:
|
|
79
|
-
title
|
|
80
|
-
state: task.state,
|
|
81
|
-
started_at:
|
|
82
|
-
finished_at: task.finished_at,
|
|
83
|
-
coverage
|
|
104
|
+
id: head.task_id,
|
|
105
|
+
title,
|
|
106
|
+
state: last.task.state,
|
|
107
|
+
started_at: head.started_at,
|
|
108
|
+
finished_at: last.task.finished_at,
|
|
109
|
+
coverage,
|
|
84
110
|
lessons: [...lessons.values()],
|
|
85
|
-
applied
|
|
86
|
-
saved
|
|
87
|
-
checks:
|
|
111
|
+
applied,
|
|
112
|
+
saved,
|
|
113
|
+
checks: checks.slice(-64),
|
|
88
114
|
conformance: [...latestRule.values()],
|
|
89
|
-
refusals
|
|
90
|
-
files: [...
|
|
91
|
-
source_snapshot:
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
refusals,
|
|
116
|
+
files: [...fromReport, ...extra].slice(0, 64),
|
|
117
|
+
source_snapshot: sourceSnapshot,
|
|
118
|
+
// One member: its own hash, so records written before episodes existed do not all refresh.
|
|
119
|
+
report_hash: closed.length === 1 ? closed[0].content_hash : reportHash(closed.map((r) => r.content_hash)),
|
|
120
|
+
provenance: { source: "task_report", confidence: 1, evidence: closed.slice(0, 20).map((r) => `hunch report ${r.task.task_id}`), last_verified: last.task.finished_at },
|
|
94
121
|
});
|
|
95
122
|
}
|
|
96
123
|
/** Where the record belongs. Anything that touched the private overlay — a
|
|
@@ -145,13 +172,38 @@ export function computeSupersedes(record, others, limit = 20) {
|
|
|
145
172
|
export function persistTaskRecord(root, store, taskId, options = {}) {
|
|
146
173
|
if (!taskRecordsEnabled(root))
|
|
147
174
|
return null;
|
|
148
|
-
const
|
|
149
|
-
const
|
|
175
|
+
const snapshot = reportSourceSnapshot(root).hash;
|
|
176
|
+
const own = readTaskReport(root, taskId, snapshot);
|
|
177
|
+
if (own.task.state === "open")
|
|
178
|
+
return null;
|
|
179
|
+
// The record covers the whole episode: this prompt and the prompts of the
|
|
180
|
+
// same session it continued. Work done outside an instrumented editor (shell
|
|
181
|
+
// edits, rebases, release commits) still anchors it: git says what changed
|
|
182
|
+
// while the episode was open.
|
|
183
|
+
const headId = own.task.episode ?? own.task.task_id;
|
|
184
|
+
const members = headId === own.task.task_id && !own.task.episode ? [own.task] : episodeTasks(root, headId);
|
|
185
|
+
const reports = (members.length ? members : [own.task]).map((t) => (t.task_id === taskId ? own : readTaskReport(root, t.task_id, snapshot)));
|
|
186
|
+
const window = { from: reports[0].task.started_at, to: reports.reduce((max, r) => (r.task.finished_at && (!max || r.task.finished_at > max) ? r.task.finished_at : max), null) };
|
|
187
|
+
// Another session working in the same checkout at the same time leaves the
|
|
188
|
+
// same mtimes: then only commits (attributable by author and time) count.
|
|
189
|
+
const workingTree = !sessionsOverlap(root, reports[0].task.session_key, window.from, window.to, reports.map((r) => r.task.task_id));
|
|
190
|
+
let built = taskRecordFromReports(reports, gitTouchedFiles(root, window.from, window.to, { workingTree }));
|
|
150
191
|
if (!built)
|
|
151
192
|
return null;
|
|
193
|
+
let inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
|
|
194
|
+
let inPublic = store.json.get("tasks", built.id);
|
|
195
|
+
// A record never changes home. If the episode's record already lives in the
|
|
196
|
+
// public store and this prompt brought private-only memory into it, the
|
|
197
|
+
// episode splits here: this prompt keeps its own record instead of naming
|
|
198
|
+
// private memory in a public one.
|
|
199
|
+
if (inPublic && !inPrivate && taskRecordHome(store, built) === "private" && built.id !== taskId) {
|
|
200
|
+
built = taskRecordFromReports([own], gitTouchedFiles(root, own.task.started_at, own.task.finished_at, { workingTree }));
|
|
201
|
+
if (!built)
|
|
202
|
+
return null;
|
|
203
|
+
inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
|
|
204
|
+
inPublic = store.json.get("tasks", built.id);
|
|
205
|
+
}
|
|
152
206
|
const record = { ...built, supersedes: computeSupersedes(built, store.recs("tasks")) };
|
|
153
|
-
const inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", record.id) : undefined;
|
|
154
|
-
const inPublic = store.json.get("tasks", record.id);
|
|
155
207
|
const home = inPrivate ? "private" : inPublic ? "public" : taskRecordHome(store, record);
|
|
156
208
|
const existing = home === "private" ? inPrivate : inPublic;
|
|
157
209
|
if (existing && existing.report_hash === record.report_hash)
|
|
@@ -202,7 +254,10 @@ export function mergeDurableTaskSummaries(store, summaries, limit = 30) {
|
|
|
202
254
|
const seen = new Set();
|
|
203
255
|
const merged = summaries.map((s) => {
|
|
204
256
|
seen.add(s.task.task_id);
|
|
205
|
-
|
|
257
|
+
// A continued prompt's record lives under its episode head.
|
|
258
|
+
const home = homes.get(s.task.task_id) ?? homes.get(s.task.episode ?? "");
|
|
259
|
+
if (s.task.episode)
|
|
260
|
+
seen.add(s.task.episode);
|
|
206
261
|
return { ...s, durable: home ? { home } : null };
|
|
207
262
|
});
|
|
208
263
|
for (const r of records.values()) {
|
|
@@ -123,6 +123,13 @@ declare const TaskSchema: z.ZodObject<{
|
|
|
123
123
|
completed: "completed";
|
|
124
124
|
interrupted: "interrupted";
|
|
125
125
|
}>;
|
|
126
|
+
closed_by: z.ZodOptional<z.ZodEnum<{
|
|
127
|
+
host: "host";
|
|
128
|
+
agent: "agent";
|
|
129
|
+
}>>;
|
|
130
|
+
session_key: z.ZodOptional<z.ZodString>;
|
|
131
|
+
continues: z.ZodOptional<z.ZodString>;
|
|
132
|
+
episode: z.ZodOptional<z.ZodString>;
|
|
126
133
|
}, z.core.$strict>;
|
|
127
134
|
export type ReportTask = z.infer<typeof TaskSchema>;
|
|
128
135
|
export interface TaskDelivery {
|
|
@@ -193,6 +200,12 @@ export interface LessonHistory {
|
|
|
193
200
|
truncated: boolean;
|
|
194
201
|
next_before: number | null;
|
|
195
202
|
}
|
|
203
|
+
/** A prompt identity that reports to another prompt's task: a host notification
|
|
204
|
+
* turn continues the session's latest task instead of opening a row. Explicit,
|
|
205
|
+
* so Stop never selects a task by recency for a prompt it does not know. */
|
|
206
|
+
export declare function aliasReportTask(root: string, aliasId: string, taskId: string): void;
|
|
207
|
+
/** The task an aliased prompt identity reports to, or the identity itself. */
|
|
208
|
+
export declare function resolveReportTask(root: string, id: string): string;
|
|
196
209
|
/** The lookup is derived, never evidence. Backfill at most 64 events and
|
|
197
210
|
* 512 KB per read. Parse outside the writer transaction so a history view cannot
|
|
198
211
|
* hold the writer lock while validating a large legacy ledger. */
|
|
@@ -200,7 +213,43 @@ export declare function readLessonHistory(root: string, reference: LessonReferen
|
|
|
200
213
|
limit?: number;
|
|
201
214
|
before?: number;
|
|
202
215
|
}): LessonHistory;
|
|
203
|
-
export
|
|
216
|
+
export interface TaskLinks {
|
|
217
|
+
session_key?: string;
|
|
218
|
+
continues?: string;
|
|
219
|
+
episode?: string;
|
|
220
|
+
}
|
|
221
|
+
export declare function startReportTask(root: string, title: string, taskId?: string, links?: TaskLinks): ReportTask;
|
|
222
|
+
/** A prompt that follows another in the same session within this window is the
|
|
223
|
+
* same work: its task continues the previous one and shares its episode. */
|
|
224
|
+
export declare const CONTINUATION_WINDOW_MS: number;
|
|
225
|
+
/** The links a new prompt's task takes from the latest task of its session, or
|
|
226
|
+
* null when that task is too old (measured from its close) to be the same work.
|
|
227
|
+
* A task still open is the session's current work however long ago it started:
|
|
228
|
+
* the prompt was interrupted before Stop, or a late observation reopened it. */
|
|
229
|
+
export declare function continuationLinks(previous: ReportTask | null, nowMs?: number): {
|
|
230
|
+
continues: string;
|
|
231
|
+
episode: string;
|
|
232
|
+
} | null;
|
|
233
|
+
/** The most recent task of a session in this worktree, or null. */
|
|
234
|
+
export declare function latestSessionTask(root: string, sessionKey: string): ReportTask | null;
|
|
235
|
+
/** Tasks of a session that an earlier prompt left open are over once the
|
|
236
|
+
* session moves on: the prompt was interrupted before its Stop, a notification
|
|
237
|
+
* turn reused the task, or a late observation reopened it. Close them as host
|
|
238
|
+
* closes, except `keepId` (the prompt now running), the session's newest task
|
|
239
|
+
* when `keepNewest` (a notification turn continues it), and any task whose
|
|
240
|
+
* verification may still deliver a result. Returns the ids closed here so the
|
|
241
|
+
* caller can persist their records. */
|
|
242
|
+
export declare function settleSessionTasks(root: string, sessionKey: string, options?: {
|
|
243
|
+
keepId?: string | null;
|
|
244
|
+
keepNewest?: boolean;
|
|
245
|
+
}): string[];
|
|
246
|
+
/** Whether a task of ANOTHER session (or of no session) was open in this
|
|
247
|
+
* worktree during the window: working-tree edits made then cannot be told apart
|
|
248
|
+
* by mtime, so the caller attributes only commits. `ownIds` are the episode's
|
|
249
|
+
* own tasks; any other task without a session key counts as foreign. */
|
|
250
|
+
export declare function sessionsOverlap(root: string, sessionKey: string | undefined, from: string, to: string | null, ownIds?: readonly string[]): boolean;
|
|
251
|
+
/** Every task of an episode, oldest first: the head and the prompts that continued it. */
|
|
252
|
+
export declare function episodeTasks(root: string, headId: string): ReportTask[];
|
|
204
253
|
/** The record revisions among `records` that this task has not received before.
|
|
205
254
|
* Powers the one-line "Hunch recalled …" indication on a lesson's FIRST delivery
|
|
206
255
|
* in a task; repeats of the same revision stay silent (deduplicated per task and
|
|
@@ -223,8 +272,21 @@ export declare function recordReportRefusal(root: string, taskId: string, refusa
|
|
|
223
272
|
export declare function recordReportConformance(root: string, taskId: string, conformance: ReportConformance): string;
|
|
224
273
|
/** Only the local runner calls this. MCP never accepts a claimed successful check. */
|
|
225
274
|
export declare function recordReportCheck(root: string, taskId: string, check: ReportCheck): string;
|
|
226
|
-
|
|
227
|
-
|
|
275
|
+
/** A start without a result blocks completion only while the runner could still
|
|
276
|
+
* deliver one: its own timeout plus a minute of grace. After that the runner is
|
|
277
|
+
* gone (a killed process, a closed laptop) and the report's unknowns already say
|
|
278
|
+
* the result was not retained; freezing the task forever would add nothing.
|
|
279
|
+
* Starts recorded before the timeout was retained use the verification ceiling. */
|
|
280
|
+
export declare const CHECK_RESULT_GRACE_MS = 60000;
|
|
281
|
+
export declare const MAX_PENDING_CHECK_MS: number;
|
|
282
|
+
export declare function beginReportCheck(root: string, taskId: string, label: string, timeoutMs?: number): string;
|
|
283
|
+
/** `by: "host"` is the lifecycle hook closing the prompt's task at Stop. It is
|
|
284
|
+
* provisional: a later observation reopens the task (see appendEvent) and an
|
|
285
|
+
* explicit agent finish, with any outcome, replaces it. Pending verification
|
|
286
|
+
* keeps the task open for either closer. */
|
|
287
|
+
export declare function finishReportTask(root: string, taskId: string, state?: "completed" | "interrupted", options?: {
|
|
288
|
+
by?: "agent" | "host";
|
|
289
|
+
}): ReportTask;
|
|
228
290
|
/** A report with no observation of any kind. Presentation surfaces may stay
|
|
229
291
|
* silent for it; the task row itself is retained so "never touched Hunch" is
|
|
230
292
|
* still countable (hunch report / the VS Code view / task list). */
|
package/dist/core/taskReport.js
CHANGED
|
@@ -82,6 +82,19 @@ const TaskSchema = z.object({
|
|
|
82
82
|
task_id: TaskIdSchema, scope: hashSchema, title: safeText(200),
|
|
83
83
|
started_at: z.string().datetime(), finished_at: z.string().datetime().nullable(),
|
|
84
84
|
state: z.enum(["open", "completed", "interrupted"]),
|
|
85
|
+
/** Who closed the task. "host": the lifecycle hook at Stop, a provisional
|
|
86
|
+
* close that a continuation reopens and an explicit agent finish overrides.
|
|
87
|
+
* Absent on rows written before this field existed (agent closes). */
|
|
88
|
+
closed_by: z.enum(["agent", "host"]).optional(),
|
|
89
|
+
/** Continuity across the prompts of one host session. `session_key` is a hash
|
|
90
|
+
* of (root, provider, session, agent), never the identifier itself; `continues`
|
|
91
|
+
* names the previous prompt's task when this prompt followed it within the
|
|
92
|
+
* continuation window; `episode` names the first task of that chain, the id
|
|
93
|
+
* the chain's graph record is written under. Absent on older rows and on
|
|
94
|
+
* tasks started without a host session (one task, one episode). */
|
|
95
|
+
session_key: hashSchema.optional(),
|
|
96
|
+
continues: TaskIdSchema.optional(),
|
|
97
|
+
episode: TaskIdSchema.optional(),
|
|
85
98
|
}).strict();
|
|
86
99
|
export const LessonReferenceSchema = z.object({
|
|
87
100
|
kind: safeText(64), record_id: safeText(512), content_hash: hashSchema.optional(),
|
|
@@ -102,10 +115,25 @@ function taskDb(root, run) {
|
|
|
102
115
|
);
|
|
103
116
|
CREATE INDEX IF NOT EXISTS report_record_lookup ON report_record_links(kind, record_id, content_hash);
|
|
104
117
|
CREATE TABLE IF NOT EXISTS report_history_progress (id INTEGER PRIMARY KEY CHECK(id = 1), through_rowid INTEGER NOT NULL);
|
|
105
|
-
INSERT OR IGNORE INTO report_history_progress VALUES (1, 0)
|
|
118
|
+
INSERT OR IGNORE INTO report_history_progress VALUES (1, 0);
|
|
119
|
+
CREATE TABLE IF NOT EXISTS report_task_aliases (alias_id TEXT PRIMARY KEY, task_id TEXT NOT NULL);`);
|
|
106
120
|
return run(db);
|
|
107
121
|
});
|
|
108
122
|
}
|
|
123
|
+
/** A prompt identity that reports to another prompt's task: a host notification
|
|
124
|
+
* turn continues the session's latest task instead of opening a row. Explicit,
|
|
125
|
+
* so Stop never selects a task by recency for a prompt it does not know. */
|
|
126
|
+
export function aliasReportTask(root, aliasId, taskId) {
|
|
127
|
+
TaskIdSchema.parse(aliasId);
|
|
128
|
+
taskDb(root, db => transaction(db, () => {
|
|
129
|
+
readTask(db, root, taskId);
|
|
130
|
+
db.prepare("INSERT OR REPLACE INTO report_task_aliases VALUES (?, ?)").run(aliasId, taskId);
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
/** The task an aliased prompt identity reports to, or the identity itself. */
|
|
134
|
+
export function resolveReportTask(root, id) {
|
|
135
|
+
return taskDb(root, db => db.prepare("SELECT task_id FROM report_task_aliases WHERE alias_id = ?").get(id)?.task_id ?? id);
|
|
136
|
+
}
|
|
109
137
|
function deliveryRecords(kind, body) {
|
|
110
138
|
if (kind === "save")
|
|
111
139
|
return [ReportSaveSchema.parse(body).record];
|
|
@@ -227,9 +255,9 @@ function transaction(db, run, readOnly = false) {
|
|
|
227
255
|
throw error;
|
|
228
256
|
}
|
|
229
257
|
}
|
|
230
|
-
export function startReportTask(root, title, taskId) {
|
|
258
|
+
export function startReportTask(root, title, taskId, links = {}) {
|
|
231
259
|
const task = TaskSchema.parse({ task_id: taskId ?? `htask_${randomBytes(12).toString("hex")}`,
|
|
232
|
-
scope: scopeOf(root), title, started_at: new Date().toISOString(), finished_at: null, state: "open" });
|
|
260
|
+
scope: scopeOf(root), title, started_at: new Date().toISOString(), finished_at: null, state: "open", ...links });
|
|
233
261
|
// Local observations have a bounded lifetime; durable project memory is untouched.
|
|
234
262
|
pruneReportHistory(root);
|
|
235
263
|
return taskDb(root, db => transaction(db, () => {
|
|
@@ -244,6 +272,79 @@ export function startReportTask(root, title, taskId) {
|
|
|
244
272
|
return task;
|
|
245
273
|
}));
|
|
246
274
|
}
|
|
275
|
+
/** A prompt that follows another in the same session within this window is the
|
|
276
|
+
* same work: its task continues the previous one and shares its episode. */
|
|
277
|
+
export const CONTINUATION_WINDOW_MS = 30 * 60_000;
|
|
278
|
+
/** The links a new prompt's task takes from the latest task of its session, or
|
|
279
|
+
* null when that task is too old (measured from its close) to be the same work.
|
|
280
|
+
* A task still open is the session's current work however long ago it started:
|
|
281
|
+
* the prompt was interrupted before Stop, or a late observation reopened it. */
|
|
282
|
+
export function continuationLinks(previous, nowMs = Date.now()) {
|
|
283
|
+
if (!previous)
|
|
284
|
+
return null;
|
|
285
|
+
const links = { continues: previous.task_id, episode: previous.episode ?? previous.task_id };
|
|
286
|
+
if (previous.state === "open")
|
|
287
|
+
return links;
|
|
288
|
+
const reference = Date.parse(previous.finished_at ?? previous.started_at);
|
|
289
|
+
if (!Number.isFinite(reference) || nowMs - reference > CONTINUATION_WINDOW_MS)
|
|
290
|
+
return null;
|
|
291
|
+
return links;
|
|
292
|
+
}
|
|
293
|
+
function newestSessionTask(db, root, sessionKey) {
|
|
294
|
+
const row = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.session_key') = ? ORDER BY rowid DESC LIMIT 1").get(...scopePair(root), sessionKey);
|
|
295
|
+
return row ? TaskSchema.parse(JSON.parse(row.body)) : null;
|
|
296
|
+
}
|
|
297
|
+
/** The most recent task of a session in this worktree, or null. */
|
|
298
|
+
export function latestSessionTask(root, sessionKey) {
|
|
299
|
+
return taskDb(root, db => newestSessionTask(db, root, sessionKey));
|
|
300
|
+
}
|
|
301
|
+
/** Check-starts still inside their own timeout plus the grace window, minus the
|
|
302
|
+
* results that arrived: while positive, a runner may still deliver a result. */
|
|
303
|
+
function pendingChecks(db, taskId) {
|
|
304
|
+
const { pending } = db.prepare(`SELECT SUM(CASE WHEN kind = 'check-start' AND (julianday('now') - julianday(at)) * 86400000 < COALESCE(json_extract(body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending FROM report_events WHERE task_id = ?`).get(taskId);
|
|
305
|
+
return pending ?? 0;
|
|
306
|
+
}
|
|
307
|
+
/** Tasks of a session that an earlier prompt left open are over once the
|
|
308
|
+
* session moves on: the prompt was interrupted before its Stop, a notification
|
|
309
|
+
* turn reused the task, or a late observation reopened it. Close them as host
|
|
310
|
+
* closes, except `keepId` (the prompt now running), the session's newest task
|
|
311
|
+
* when `keepNewest` (a notification turn continues it), and any task whose
|
|
312
|
+
* verification may still deliver a result. Returns the ids closed here so the
|
|
313
|
+
* caller can persist their records. */
|
|
314
|
+
export function settleSessionTasks(root, sessionKey, options = {}) {
|
|
315
|
+
return taskDb(root, db => transaction(db, () => {
|
|
316
|
+
const rows = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.session_key') = ? AND json_extract(body, '$.state') = 'open' ORDER BY rowid").all(...scopePair(root), sessionKey);
|
|
317
|
+
const newest = options.keepNewest ? newestSessionTask(db, root, sessionKey)?.task_id : undefined;
|
|
318
|
+
const closed = [];
|
|
319
|
+
for (const row of rows) {
|
|
320
|
+
const task = TaskSchema.parse(JSON.parse(row.body));
|
|
321
|
+
if (task.task_id === options.keepId || task.task_id === newest || pendingChecks(db, task.task_id) > 0)
|
|
322
|
+
continue;
|
|
323
|
+
db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(TaskSchema.parse({ ...task, state: "completed", finished_at: new Date().toISOString(), closed_by: "host" })), task.task_id);
|
|
324
|
+
closed.push(task.task_id);
|
|
325
|
+
}
|
|
326
|
+
return closed;
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
/** Whether a task of ANOTHER session (or of no session) was open in this
|
|
330
|
+
* worktree during the window: working-tree edits made then cannot be told apart
|
|
331
|
+
* by mtime, so the caller attributes only commits. `ownIds` are the episode's
|
|
332
|
+
* own tasks; any other task without a session key counts as foreign. */
|
|
333
|
+
export function sessionsOverlap(root, sessionKey, from, to, ownIds = []) {
|
|
334
|
+
return taskDb(root, db => {
|
|
335
|
+
const rows = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.started_at') <= ? AND (json_extract(body, '$.finished_at') IS NULL OR json_extract(body, '$.finished_at') >= ?)").all(...scopePair(root), to ?? new Date().toISOString(), from);
|
|
336
|
+
return rows.some(r => {
|
|
337
|
+
const task = TaskSchema.parse(JSON.parse(r.body));
|
|
338
|
+
if (ownIds.includes(task.task_id))
|
|
339
|
+
return false;
|
|
340
|
+
return !sessionKey || !task.session_key || task.session_key !== sessionKey;
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/** Every task of an episode, oldest first: the head and the prompts that continued it. */
|
|
345
|
+
export function episodeTasks(root, headId) {
|
|
346
|
+
return taskDb(root, db => db.prepare("SELECT body FROM report_tasks WHERE task_id = ? OR json_extract(body, '$.episode') = ? ORDER BY rowid").all(headId, headId).map(r => TaskSchema.parse(JSON.parse(r.body))));
|
|
347
|
+
}
|
|
247
348
|
function appendEvent(root, taskId, kind, body, eventId) {
|
|
248
349
|
const encoded = JSON.stringify(body);
|
|
249
350
|
if (Buffer.byteLength(encoded) > MAX_EVENT_BYTES)
|
|
@@ -260,30 +361,47 @@ function appendEvent(root, taskId, kind, body, eventId) {
|
|
|
260
361
|
throw new Error("report event identity conflicts with existing evidence");
|
|
261
362
|
return id;
|
|
262
363
|
}
|
|
263
|
-
|
|
264
|
-
|
|
364
|
+
// The task the observation lands on: the named one, unless the host closed
|
|
365
|
+
// it and the session has since moved on.
|
|
366
|
+
let target = task;
|
|
367
|
+
if (task.state !== "open" && !(kind === "check" && task.state === "interrupted")) {
|
|
368
|
+
if (task.closed_by !== "host")
|
|
369
|
+
throw new Error("task is already closed; start a new task for new work");
|
|
370
|
+
// The host closed this task at Stop. When a later prompt of the session
|
|
371
|
+
// exists, this is that prompt's work named by an old id (the grounding
|
|
372
|
+
// says to reuse ids): it lands on the session's newest task, which the
|
|
373
|
+
// next Stop closes, instead of reopening one no Stop would close again.
|
|
374
|
+
// Verification stays on its own task (a result must match its start), and
|
|
375
|
+
// a task the agent closed is final. Otherwise the turn went on (another
|
|
376
|
+
// hook's block, a resumed prompt): reopen; the next Stop closes it again.
|
|
377
|
+
const newest = kind === "check" || kind === "check-start" || !task.session_key ? null : newestSessionTask(db, root, task.session_key);
|
|
378
|
+
if (newest && newest.task_id !== task.task_id && (newest.state === "open" || newest.closed_by === "host"))
|
|
379
|
+
target = newest;
|
|
380
|
+
if (target.state !== "open") {
|
|
381
|
+
target = TaskSchema.parse({ ...target, state: "open", finished_at: null, closed_by: undefined });
|
|
382
|
+
db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(target), target.task_id);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const tid = target.task_id;
|
|
265
386
|
if (kind === "check") {
|
|
266
387
|
const check = ReportCheckSchema.parse(body);
|
|
267
|
-
if (!check.check_id || !db.prepare("SELECT event_id FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'check-start'").get(check.check_id,
|
|
388
|
+
if (!check.check_id || !db.prepare("SELECT event_id FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'check-start'").get(check.check_id, tid))
|
|
268
389
|
throw new Error("verification result has no matching start in this task");
|
|
269
390
|
}
|
|
270
|
-
const { total, bytes
|
|
271
|
-
|
|
272
|
-
SUM(CASE WHEN kind = 'check-start' THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending
|
|
273
|
-
FROM report_events WHERE task_id = ?`).get(taskId);
|
|
274
|
-
const reserved = Math.max(0, (pending ?? 0) + (kind === "check-start" ? 1 : kind === "check" ? -1 : 0));
|
|
391
|
+
const { total, bytes } = db.prepare("SELECT COUNT(*) AS total, COALESCE(SUM(length(CAST(body AS BLOB))), 0) AS bytes FROM report_events WHERE task_id = ?").get(tid);
|
|
392
|
+
const reserved = Math.max(0, pendingChecks(db, tid) + (kind === "check-start" ? 1 : kind === "check" ? -1 : 0));
|
|
275
393
|
if (total + 1 + reserved > MAX_EVENTS || bytes + Buffer.byteLength(encoded) + reserved * MAX_EVENT_BYTES > MAX_TASK_BYTES)
|
|
276
394
|
throw new Error("task observation limit reached; start a new task");
|
|
277
395
|
if (kind === "claim") {
|
|
278
396
|
const claim = ReportClaimSchema.parse(body);
|
|
279
|
-
const row = db.prepare("SELECT body FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'delivery'").get(claim.occurrence_id,
|
|
397
|
+
const row = db.prepare("SELECT body FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'delivery'").get(claim.occurrence_id, tid);
|
|
280
398
|
if (!row)
|
|
281
399
|
throw new Error("claim does not refer to a delivery in this task");
|
|
282
400
|
const delivery = JSON.parse(row.body);
|
|
283
401
|
if (!delivery.records.some(r => r.record_id === claim.record_id && r.content_hash === claim.content_hash))
|
|
284
402
|
throw new Error("claim record revision was not delivered in this task");
|
|
285
403
|
}
|
|
286
|
-
const inserted = db.prepare("INSERT INTO report_events VALUES (?, ?, ?, ?, ?, ?)").run(id,
|
|
404
|
+
const inserted = db.prepare("INSERT INTO report_events VALUES (?, ?, ?, ?, ?, ?)").run(id, tid, kind, new Date().toISOString(), encoded, contentHash);
|
|
287
405
|
indexDeliveryRecords(db, id, kind, body);
|
|
288
406
|
// Advance only over contiguous observed inserts; an older writer may have
|
|
289
407
|
// left unindexed events. Historical gaps are filled by bounded reads above.
|
|
@@ -361,23 +479,41 @@ export function recordReportCheck(root, taskId, check) {
|
|
|
361
479
|
throw new Error("verification result requires a reserved check identity");
|
|
362
480
|
return appendEvent(root, taskId, "check", value, `hev_${reportHash({ taskId, check: value.check_id }).slice(7, 31)}`);
|
|
363
481
|
}
|
|
364
|
-
|
|
365
|
-
|
|
482
|
+
/** A start without a result blocks completion only while the runner could still
|
|
483
|
+
* deliver one: its own timeout plus a minute of grace. After that the runner is
|
|
484
|
+
* gone (a killed process, a closed laptop) and the report's unknowns already say
|
|
485
|
+
* the result was not retained; freezing the task forever would add nothing.
|
|
486
|
+
* Starts recorded before the timeout was retained use the verification ceiling. */
|
|
487
|
+
export const CHECK_RESULT_GRACE_MS = 60_000;
|
|
488
|
+
export const MAX_PENDING_CHECK_MS = 6 * 60 * 60_000;
|
|
489
|
+
export function beginReportCheck(root, taskId, label, timeoutMs) {
|
|
490
|
+
const timeout = Number.isInteger(timeoutMs) && timeoutMs > 0 ? { timeout_ms: timeoutMs } : {};
|
|
491
|
+
return appendEvent(root, taskId, "check-start", { label: safeText(200).parse(label), ...timeout });
|
|
366
492
|
}
|
|
367
|
-
|
|
493
|
+
/** `by: "host"` is the lifecycle hook closing the prompt's task at Stop. It is
|
|
494
|
+
* provisional: a later observation reopens the task (see appendEvent) and an
|
|
495
|
+
* explicit agent finish, with any outcome, replaces it. Pending verification
|
|
496
|
+
* keeps the task open for either closer. */
|
|
497
|
+
export function finishReportTask(root, taskId, state = "completed", options = {}) {
|
|
498
|
+
const by = options.by ?? "agent";
|
|
368
499
|
return taskDb(root, db => transaction(db, () => {
|
|
369
500
|
const task = readTask(db, root, taskId);
|
|
370
501
|
if (task.state !== "open") {
|
|
502
|
+
if (task.closed_by === "host" && by === "agent") {
|
|
503
|
+
const confirmed = TaskSchema.parse({ ...task, state, finished_at: task.state === state ? task.finished_at : new Date().toISOString(), closed_by: "agent" });
|
|
504
|
+
db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(confirmed), taskId);
|
|
505
|
+
return confirmed;
|
|
506
|
+
}
|
|
371
507
|
if (task.state !== state)
|
|
372
508
|
throw new Error("task already closed with a different outcome");
|
|
373
509
|
return task;
|
|
374
510
|
}
|
|
375
511
|
if (state === "completed") {
|
|
376
|
-
const { pending } = db.prepare(`SELECT SUM(CASE WHEN kind = 'check-start' THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending FROM report_events WHERE task_id = ?`).get(taskId);
|
|
512
|
+
const { pending } = db.prepare(`SELECT SUM(CASE WHEN kind = 'check-start' AND (julianday('now') - julianday(at)) * 86400000 < COALESCE(json_extract(body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending FROM report_events WHERE task_id = ?`).get(taskId);
|
|
377
513
|
if ((pending ?? 0) > 0)
|
|
378
514
|
throw new Error("verification is still running or was interrupted; wait for its result or close the task as interrupted");
|
|
379
515
|
}
|
|
380
|
-
const finished = TaskSchema.parse({ ...task, state, finished_at: new Date().toISOString() });
|
|
516
|
+
const finished = TaskSchema.parse({ ...task, state, finished_at: new Date().toISOString(), closed_by: by });
|
|
381
517
|
db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(finished), taskId);
|
|
382
518
|
return finished;
|
|
383
519
|
}));
|
|
@@ -17,7 +17,10 @@ export declare function reportSourceSnapshot(root: string): ReportSnapshot;
|
|
|
17
17
|
* predicate's subject lives in a changed file. Everything else stays
|
|
18
18
|
* `not-exercised` or `unavailable` — never "satisfied" by file overlap. */
|
|
19
19
|
export declare function runReportConformance(root: string, store: HunchStore, taskId: string): ReportConformance[];
|
|
20
|
-
|
|
20
|
+
/** A full suite is the usual check; two minutes turned passing suites into
|
|
21
|
+
* recorded timeouts (#268). The bound is a safety net for an abandoned runner,
|
|
22
|
+
* not a verdict. */
|
|
23
|
+
export declare const DEFAULT_CHECK_TIMEOUT_MS: number;
|
|
21
24
|
export declare const MAX_CHECK_TIMEOUT_MS: number;
|
|
22
25
|
/** A deliberately explicit command wrapper. The caller chooses the command;
|
|
23
26
|
* reports never execute commands automatically to validate submitted claims. */
|
|
@@ -96,7 +96,7 @@ export function reportSourceSnapshot(root) {
|
|
|
96
96
|
* `not-exercised` or `unavailable` — never "satisfied" by file overlap. */
|
|
97
97
|
export function runReportConformance(root, store, taskId) {
|
|
98
98
|
const report = readTaskReport(root, taskId);
|
|
99
|
-
if (report.task.state !== "open")
|
|
99
|
+
if (report.task.state !== "open" && report.task.closed_by !== "host")
|
|
100
100
|
throw new Error("cannot evaluate rules for a closed task");
|
|
101
101
|
const delivered = [...new Map(report.deliveries.flatMap(d => d.records).filter(r => r.kind === "constraints" || r.kind === "decisions").map(r => [`${r.kind}:${r.record_id}:${r.content_hash}`, r])).values()];
|
|
102
102
|
if (!delivered.length)
|
|
@@ -192,23 +192,26 @@ export function runReportConformance(root, store, taskId) {
|
|
|
192
192
|
return value;
|
|
193
193
|
});
|
|
194
194
|
}
|
|
195
|
-
|
|
195
|
+
/** A full suite is the usual check; two minutes turned passing suites into
|
|
196
|
+
* recorded timeouts (#268). The bound is a safety net for an abandoned runner,
|
|
197
|
+
* not a verdict. */
|
|
198
|
+
export const DEFAULT_CHECK_TIMEOUT_MS = 15 * 60_000;
|
|
196
199
|
export const MAX_CHECK_TIMEOUT_MS = 6 * 60 * 60_000;
|
|
197
200
|
/** A deliberately explicit command wrapper. The caller chooses the command;
|
|
198
201
|
* reports never execute commands automatically to validate submitted claims. */
|
|
199
|
-
export async function runReportCheck(root, taskId, command, label, timeoutMs =
|
|
202
|
+
export async function runReportCheck(root, taskId, command, label, timeoutMs = DEFAULT_CHECK_TIMEOUT_MS, options = {}) {
|
|
200
203
|
// A full suite can legitimately run for half an hour (fnd_70dd5c4034); the
|
|
201
204
|
// bound exists so an abandoned runner cannot hold a task open indefinitely.
|
|
202
205
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_CHECK_TIMEOUT_MS)
|
|
203
206
|
throw new Error(`verification timeout must be between 1 and ${MAX_CHECK_TIMEOUT_MS} ms`);
|
|
204
207
|
options.signal?.throwIfAborted();
|
|
205
208
|
const task = readTaskReport(root, taskId).task;
|
|
206
|
-
if (task.state !== "open")
|
|
209
|
+
if (task.state !== "open" && task.closed_by !== "host")
|
|
207
210
|
throw new Error("cannot verify a closed task");
|
|
208
211
|
const before = reportSourceSnapshot(root);
|
|
209
212
|
// Validate sensitive arguments before executing or writing anything.
|
|
210
213
|
ReportCheckSchema.parse({ label, command, exit_code: null, output_hash: reportHash(""), before_snapshot: before.hash, after_snapshot: null, snapshot_limitations: before.limitations, timed_out: false, source: "local-command-runner" });
|
|
211
|
-
const checkId = beginReportCheck(root, taskId, label);
|
|
214
|
+
const checkId = beginReportCheck(root, taskId, label, timeoutMs);
|
|
212
215
|
const result = await new Promise((resolveResult) => {
|
|
213
216
|
// Windows launchers (npx.cmd, npm.cmd, other .cmd/.bat shims) cannot be spawned
|
|
214
217
|
// without a shell; resolve them first so a check actually runs instead of
|
|
@@ -21,8 +21,30 @@ export declare function hookReportTaskId(root: string, provider: HookProvider, e
|
|
|
21
21
|
* No raw prompt, host session identifier, or transcript is retained; a repository
|
|
22
22
|
* that opts in (`taskTitles: "prompt"`) keeps only a bounded first-line title. */
|
|
23
23
|
export declare function startHookReport(root: string, provider: HookProvider, event: HunchHookInput): string | null;
|
|
24
|
-
/** A
|
|
25
|
-
*
|
|
24
|
+
/** A prompt the host generated to report a background command's completion,
|
|
25
|
+
* not something the user typed. */
|
|
26
|
+
export declare function isNotificationPrompt(prompt: string | undefined): boolean;
|
|
27
|
+
/** Close, as host closes, the tasks of this session that an earlier prompt left
|
|
28
|
+
* open: the prompt was interrupted before its Stop, or a late observation
|
|
29
|
+
* reopened its task. Called when a new prompt starts (`keepNewest`: the new
|
|
30
|
+
* task, or the task a notification turn continues, stays open) and when the
|
|
31
|
+
* current prompt stops. Returns the ids closed here for the caller to persist. */
|
|
32
|
+
export declare function settleHookSession(root: string, provider: HookProvider, event: HunchHookInput, options?: {
|
|
33
|
+
keepId?: string | null;
|
|
34
|
+
keepNewest?: boolean;
|
|
35
|
+
}): string[];
|
|
36
|
+
/** Stop ends the turn, so the prompt's task closes here as a HOST close: the
|
|
37
|
+
* ledger says the task completed even when the agent never called finish, and
|
|
38
|
+
* a task with observations becomes a graph record without anyone's cooperation.
|
|
39
|
+
* The close is provisional because Stop can precede another hook's
|
|
40
|
+
* continuation: the next observation reopens the task and the following Stop
|
|
41
|
+
* closes it again (the record is refreshed from the report). An explicit agent
|
|
42
|
+
* finish with any outcome overrides a host close. Pending verification keeps
|
|
43
|
+
* the task open. Tasks an earlier prompt of the session left open close here
|
|
44
|
+
* too. Returns the ids of the tasks closed after this call, so the caller can
|
|
45
|
+
* persist their records; empty when nothing is closed. */
|
|
46
|
+
export declare function closeHookTask(root: string, provider: HookProvider, event: HunchHookInput): string[];
|
|
47
|
+
/** A presentation notice never denies Stop or injects another model turn.
|
|
26
48
|
* A prompt with no observation at all prints nothing: the empty task row stays
|
|
27
49
|
* in the ledger (hunch task list, the VS Code Contribution view) so "never
|
|
28
50
|
* touched Hunch" remains countable without a five-line notice per prompt. */
|
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { findRoot } from "./paths.js";
|
|
6
6
|
import { canonicalReportRoot } from "./taskReportPaths.js";
|
|
7
7
|
import { isCredentialFreeText } from "./types.js";
|
|
8
|
-
import { isEmptyTaskReport, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, startReportTask } from "./taskReport.js";
|
|
8
|
+
import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
|
|
9
9
|
import { reportSourceSnapshot } from "./taskReportEvidence.js";
|
|
10
10
|
import { renderTaskReport } from "./taskReportRender.js";
|
|
11
11
|
/** The exact task identity a native host prompt maps to. */
|
|
@@ -73,7 +73,14 @@ function identity(root, provider, event) {
|
|
|
73
73
|
return null;
|
|
74
74
|
if (!event.prompt_id)
|
|
75
75
|
return "legacy";
|
|
76
|
-
|
|
76
|
+
const id = promptTaskId(root, event.session_id, event.prompt_id, event.agent_id ?? null, provider);
|
|
77
|
+
// A notification turn reports to the task it continued (an explicit alias).
|
|
78
|
+
try {
|
|
79
|
+
return resolveReportTask(root, id);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return id;
|
|
83
|
+
}
|
|
77
84
|
}
|
|
78
85
|
export function hookReportTaskId(root, provider, event) {
|
|
79
86
|
try {
|
|
@@ -98,9 +105,33 @@ export function startHookReport(root, provider, event) {
|
|
|
98
105
|
return null;
|
|
99
106
|
const cwdLiteral = JSON.stringify(cwd);
|
|
100
107
|
const title = (promptTitlesEnabled(root) ? promptTaskTitle(event.prompt) : null) ?? NATIVE_TASK_TITLE;
|
|
108
|
+
// Continuity: a prompt that follows another of the same session within the
|
|
109
|
+
// window continues its task ("status", "next", "go" are the same work), and
|
|
110
|
+
// the episode's graph record is written under the first task's id. The key
|
|
111
|
+
// is a hash; the host session identifier itself is still never retained.
|
|
112
|
+
let links = {};
|
|
113
|
+
const sessionKey = hookSessionKey(cwd, provider, event);
|
|
114
|
+
if (sessionKey) {
|
|
115
|
+
links = { session_key: sessionKey };
|
|
116
|
+
try {
|
|
117
|
+
const previous = latestSessionTask(root, sessionKey);
|
|
118
|
+
// A host notification (a background command finished) is not new work:
|
|
119
|
+
// it continues the session's latest task instead of opening an empty row,
|
|
120
|
+
// unless the agent already closed that task for good. The alias makes
|
|
121
|
+
// this prompt's Stop and hook observations report to that task.
|
|
122
|
+
if (previous && previous.task_id !== id && isNotificationPrompt(event.prompt) && previous.closed_by !== "agent") {
|
|
123
|
+
aliasReportTask(root, id, previous.task_id);
|
|
124
|
+
return taskInstruction(previous, cwdLiteral);
|
|
125
|
+
}
|
|
126
|
+
const continued = previous && previous.task_id !== id ? continuationLinks(previous) : null;
|
|
127
|
+
if (continued)
|
|
128
|
+
links = { ...links, ...continued };
|
|
129
|
+
}
|
|
130
|
+
catch { /* no continuity; still a task */ }
|
|
131
|
+
}
|
|
101
132
|
let task;
|
|
102
133
|
try {
|
|
103
|
-
task = startReportTask(root, title, id);
|
|
134
|
+
task = startReportTask(root, title, id, links);
|
|
104
135
|
}
|
|
105
136
|
catch (error) {
|
|
106
137
|
// The same prompt identity may already be open: a release that called every
|
|
@@ -111,10 +142,78 @@ export function startHookReport(root, provider, event) {
|
|
|
111
142
|
throw error;
|
|
112
143
|
task = existing;
|
|
113
144
|
}
|
|
145
|
+
return taskInstruction(task, cwdLiteral);
|
|
146
|
+
}
|
|
147
|
+
function taskInstruction(task, cwdLiteral) {
|
|
114
148
|
return `Hunch has opened this prompt's report: ${task.task_id}. Reuse this exact ID for this prompt. Call hunch_task(action: "start", task_id: "${task.task_id}", title: ${JSON.stringify(task.title)}, cwd: ${cwdLiteral}) to obtain verification_argv; do not create another report. Pass this task_id and cwd: ${cwdLiteral} to hunch_context and decision/correction/finding captures, and pass the same cwd when finishing with hunch_task before responding. A host Stop notice will show the evidence even if no task-linked memory was observed.`;
|
|
115
149
|
}
|
|
116
|
-
/**
|
|
117
|
-
*
|
|
150
|
+
/** The session key a hook event maps to: a hash of (root, provider, session,
|
|
151
|
+
* agent), never the identifier itself. Null without a host session. */
|
|
152
|
+
function hookSessionKey(cwd, provider, event) {
|
|
153
|
+
return event.session_id ? reportHash([cwd, provider, event.session_id, event.agent_id ?? null]) : null;
|
|
154
|
+
}
|
|
155
|
+
/** A prompt the host generated to report a background command's completion,
|
|
156
|
+
* not something the user typed. */
|
|
157
|
+
export function isNotificationPrompt(prompt) {
|
|
158
|
+
if (typeof prompt !== "string")
|
|
159
|
+
return false;
|
|
160
|
+
const firstLine = prompt.split(/\r?\n/).map(l => l.trim()).find(l => l.length > 0) ?? "";
|
|
161
|
+
return /^<task-notification>/i.test(firstLine);
|
|
162
|
+
}
|
|
163
|
+
/** Close, as host closes, the tasks of this session that an earlier prompt left
|
|
164
|
+
* open: the prompt was interrupted before its Stop, or a late observation
|
|
165
|
+
* reopened its task. Called when a new prompt starts (`keepNewest`: the new
|
|
166
|
+
* task, or the task a notification turn continues, stays open) and when the
|
|
167
|
+
* current prompt stops. Returns the ids closed here for the caller to persist. */
|
|
168
|
+
export function settleHookSession(root, provider, event, options = {}) {
|
|
169
|
+
const cwd = nativeHookCwd(root, provider, event);
|
|
170
|
+
const key = cwd ? hookSessionKey(cwd, provider, event) : null;
|
|
171
|
+
if (!key)
|
|
172
|
+
return [];
|
|
173
|
+
try {
|
|
174
|
+
return settleSessionTasks(root, key, options);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Stop ends the turn, so the prompt's task closes here as a HOST close: the
|
|
181
|
+
* ledger says the task completed even when the agent never called finish, and
|
|
182
|
+
* a task with observations becomes a graph record without anyone's cooperation.
|
|
183
|
+
* The close is provisional because Stop can precede another hook's
|
|
184
|
+
* continuation: the next observation reopens the task and the following Stop
|
|
185
|
+
* closes it again (the record is refreshed from the report). An explicit agent
|
|
186
|
+
* finish with any outcome overrides a host close. Pending verification keeps
|
|
187
|
+
* the task open. Tasks an earlier prompt of the session left open close here
|
|
188
|
+
* too. Returns the ids of the tasks closed after this call, so the caller can
|
|
189
|
+
* persist their records; empty when nothing is closed. */
|
|
190
|
+
export function closeHookTask(root, provider, event) {
|
|
191
|
+
let id;
|
|
192
|
+
try {
|
|
193
|
+
id = identity(root, provider, event);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
if (!id || id === "legacy")
|
|
199
|
+
return [];
|
|
200
|
+
const closed = [];
|
|
201
|
+
try {
|
|
202
|
+
const task = readTaskReport(root, id).task;
|
|
203
|
+
if (task.state === "open")
|
|
204
|
+
finishReportTask(root, id, "completed", { by: "host" });
|
|
205
|
+
if (task.state !== "interrupted")
|
|
206
|
+
closed.push(id);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// No task for this prompt (a notification turn), or verification still running: leave it as it is.
|
|
210
|
+
}
|
|
211
|
+
for (const other of settleHookSession(root, provider, event, { keepId: id }))
|
|
212
|
+
if (!closed.includes(other))
|
|
213
|
+
closed.push(other);
|
|
214
|
+
return closed;
|
|
215
|
+
}
|
|
216
|
+
/** A presentation notice never denies Stop or injects another model turn.
|
|
118
217
|
* A prompt with no observation at all prints nothing: the empty task row stays
|
|
119
218
|
* in the ledger (hunch task list, the VS Code Contribution view) so "never
|
|
120
219
|
* touched Hunch" remains countable without a five-line notice per prompt. */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Files Hunch regenerates on every capture (src/integrations/providers.ts,
|
|
2
|
+
* claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
|
|
3
|
+
export declare const HUNCH_MANAGED_FILES: ReadonlySet<string>;
|
|
4
|
+
export declare function gitTouchedFiles(root: string, startedAt: string, finishedAt: string | null, options?: {
|
|
5
|
+
limit?: number;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
now?: number;
|
|
8
|
+
workingTree?: boolean;
|
|
9
|
+
}): string[];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Files the repository shows as worked on while a task was open.
|
|
2
|
+
*
|
|
3
|
+
* The pre-edit hook only sees edits made through an instrumented editor tool.
|
|
4
|
+
* Work done from a shell (patch scripts, rebases, release commits) never
|
|
5
|
+
* produced a delivery, so the task record had no file anchor for it and the
|
|
6
|
+
* ranking could not relate the task to later work on the same files. Two
|
|
7
|
+
* sources fill that gap, both bounded and fail-open (an error yields nothing,
|
|
8
|
+
* never a failed finish):
|
|
9
|
+
* - commits authored by the configured git user whose commit time falls in
|
|
10
|
+
* the task window (merges excluded);
|
|
11
|
+
* - working-tree changes (modified, added, untracked) whose mtime falls in it,
|
|
12
|
+
* unless the caller knows another session shared the checkout (`workingTree:
|
|
13
|
+
* false`): mtimes cannot say whose edit it was, commits can.
|
|
14
|
+
* Hunch's own work never counts as the task's: memory and cache paths, commits
|
|
15
|
+
* Hunch makes (`hunch:` subjects — captures, task records, repairs), and the
|
|
16
|
+
* grounding files a capture rewrites (CLAUDE.md, AGENTS.md, the host rule
|
|
17
|
+
* files) when they merely changed in the working tree; a user commit that
|
|
18
|
+
* edits one of those files still counts, as does a delivery that named it.
|
|
19
|
+
* Deleted paths are skipped: nothing dates the deletion. Commit dates get one
|
|
20
|
+
* second of slack (git keeps seconds); working-tree mtimes get none before
|
|
21
|
+
* the start. */
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import { statSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
const EXCLUDED_SEGMENTS = new Set([".hunch", ".hunch-cache", ".git"]);
|
|
26
|
+
/** Files Hunch regenerates on every capture (src/integrations/providers.ts,
|
|
27
|
+
* claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
|
|
28
|
+
export const HUNCH_MANAGED_FILES = new Set(["CLAUDE.md", "AGENTS.md", ".cursor/rules/hunch.mdc", ".github/copilot-instructions.md", ".windsurf/rules/hunch.md"]);
|
|
29
|
+
/** Commit subjects Hunch writes itself. */
|
|
30
|
+
const HUNCH_COMMIT_SUBJECT = /^hunch:/i;
|
|
31
|
+
function gitDate(ms) {
|
|
32
|
+
// Second resolution, a format every git accepts.
|
|
33
|
+
return `${new Date(ms).toISOString().slice(0, 19).replace("T", " ")} +0000`;
|
|
34
|
+
}
|
|
35
|
+
export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
|
|
36
|
+
const limit = Math.max(1, options.limit ?? 64);
|
|
37
|
+
const timeout = options.timeoutMs ?? 3_000;
|
|
38
|
+
const since = Date.parse(startedAt);
|
|
39
|
+
if (!Number.isFinite(since))
|
|
40
|
+
return [];
|
|
41
|
+
const until = finishedAt ? Date.parse(finishedAt) : (options.now ?? Date.now());
|
|
42
|
+
if (!Number.isFinite(until) || until < since)
|
|
43
|
+
return [];
|
|
44
|
+
// One second of slack on each side: git dates and some filesystems are second-granular.
|
|
45
|
+
const from = since - 1_000, to = until + 1_000;
|
|
46
|
+
const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
|
|
47
|
+
for (const key of Object.keys(env))
|
|
48
|
+
if (key.startsWith("GIT_") && key !== "GIT_OPTIONAL_LOCKS")
|
|
49
|
+
delete env[key];
|
|
50
|
+
const run = (args) => execFileSync("git", ["-C", root, "-c", "core.quotePath=false", ...args], { env, encoding: "utf8", timeout, maxBuffer: 4_000_000, stdio: ["ignore", "pipe", "ignore"] });
|
|
51
|
+
const out = new Set();
|
|
52
|
+
const normalize = (raw) => raw.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
53
|
+
const keep = (raw) => {
|
|
54
|
+
const path = normalize(raw);
|
|
55
|
+
if (!path || path.split("/").some((segment) => EXCLUDED_SEGMENTS.has(segment)))
|
|
56
|
+
return;
|
|
57
|
+
out.add(path);
|
|
58
|
+
};
|
|
59
|
+
try {
|
|
60
|
+
const email = run(["config", "--get", "user.email"]).trim();
|
|
61
|
+
if (email) {
|
|
62
|
+
// One record per commit: a separator, the subject, then the paths.
|
|
63
|
+
const log = run(["log", "--no-merges", "-n", "50", `--since=${gitDate(from)}`, `--until=${gitDate(to)}`, `--author=${email}`, "--format=%x1e%s", "--name-only"]);
|
|
64
|
+
for (const block of log.split("\x1e")) {
|
|
65
|
+
const [subject = "", ...paths] = block.split("\n");
|
|
66
|
+
if (HUNCH_COMMIT_SUBJECT.test(subject.trim()))
|
|
67
|
+
continue;
|
|
68
|
+
for (const line of paths)
|
|
69
|
+
keep(line);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch { /* no commits, no git user, or no git: the working tree may still say something */ }
|
|
74
|
+
if (options.workingTree === false)
|
|
75
|
+
return [...out].sort().slice(0, limit);
|
|
76
|
+
try {
|
|
77
|
+
const entries = run(["status", "--porcelain=v1", "-z", "--untracked-files=all"]).split("\0").filter(Boolean);
|
|
78
|
+
for (let i = 0; i < entries.length; i++) {
|
|
79
|
+
const entry = entries[i];
|
|
80
|
+
const code = entry.slice(0, 2), path = entry.slice(3);
|
|
81
|
+
// A rename or copy is followed by its original path as a separate entry.
|
|
82
|
+
if (code[0] === "R" || code[0] === "C")
|
|
83
|
+
i++;
|
|
84
|
+
if (code.includes("D") || !path || HUNCH_MANAGED_FILES.has(normalize(path)))
|
|
85
|
+
continue;
|
|
86
|
+
try {
|
|
87
|
+
const mtime = statSync(join(root, path)).mtimeMs;
|
|
88
|
+
if (mtime >= since && mtime <= to)
|
|
89
|
+
keep(path);
|
|
90
|
+
}
|
|
91
|
+
catch { /* vanished between status and stat */ }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch { /* not a git worktree or status failed: nothing to add */ }
|
|
95
|
+
return [...out].sort().slice(0, limit);
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=taskTouched.js.map
|
|
@@ -16,6 +16,10 @@ export declare function boundedTaskReport(report: ReturnType<typeof readTaskRepo
|
|
|
16
16
|
started_at: string;
|
|
17
17
|
finished_at: string | null;
|
|
18
18
|
state: "open" | "completed" | "interrupted";
|
|
19
|
+
closed_by?: "host" | "agent" | undefined;
|
|
20
|
+
session_key?: string | undefined;
|
|
21
|
+
continues?: string | undefined;
|
|
22
|
+
episode?: string | undefined;
|
|
19
23
|
};
|
|
20
24
|
coverage: "no-delivery-observed" | "no-relevant-memory" | "delivered";
|
|
21
25
|
content_hash: string;
|
|
@@ -105,6 +109,10 @@ export declare function boundedTaskReportForHost(report: ReturnType<typeof readT
|
|
|
105
109
|
started_at: string;
|
|
106
110
|
finished_at: string | null;
|
|
107
111
|
state: "open" | "completed" | "interrupted";
|
|
112
|
+
closed_by?: "host" | "agent" | undefined;
|
|
113
|
+
session_key?: string | undefined;
|
|
114
|
+
continues?: string | undefined;
|
|
115
|
+
episode?: string | undefined;
|
|
108
116
|
};
|
|
109
117
|
coverage: "no-delivery-observed" | "no-relevant-memory" | "delivered";
|
|
110
118
|
content_hash: string;
|
|
@@ -185,4 +193,13 @@ export declare function boundedTaskReportForHost(report: ReturnType<typeof readT
|
|
|
185
193
|
};
|
|
186
194
|
full_report: string;
|
|
187
195
|
};
|
|
196
|
+
/** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
|
|
197
|
+
* loader; a published `.js` build needs nothing) and `resolve` is that
|
|
198
|
+
* module's `import.meta.resolve`. The loader is resolved ONLY on the source
|
|
199
|
+
* path: `import.meta.resolve` throws for a package that is not installed, and
|
|
200
|
+
* `tsx` is a devDependency absent from every published install (#261). */
|
|
201
|
+
export declare function verificationLauncherFor(metaUrl: string, resolve: (specifier: string) => string): {
|
|
202
|
+
argv: string[];
|
|
203
|
+
shell: string;
|
|
204
|
+
};
|
|
188
205
|
export declare function registerTaskReportTools(server: McpServer, getRoot: () => string, getStore: () => HunchStore): void;
|
|
@@ -51,13 +51,21 @@ export function boundedTaskReportForHost(report) {
|
|
|
51
51
|
/** Reuse the MCP server's installation, not a potentially stale global binary.
|
|
52
52
|
* Structured argv is authoritative; the shell hint uses literal quoting. */
|
|
53
53
|
function verificationLauncher() {
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
return verificationLauncherFor(import.meta.url, (specifier) => import.meta.resolve(specifier));
|
|
55
|
+
}
|
|
56
|
+
/** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
|
|
57
|
+
* loader; a published `.js` build needs nothing) and `resolve` is that
|
|
58
|
+
* module's `import.meta.resolve`. The loader is resolved ONLY on the source
|
|
59
|
+
* path: `import.meta.resolve` throws for a package that is not installed, and
|
|
60
|
+
* `tsx` is a devDependency absent from every published install (#261). */
|
|
61
|
+
export function verificationLauncherFor(metaUrl, resolve) {
|
|
62
|
+
const dev = metaUrl.endsWith(".ts");
|
|
63
|
+
const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, metaUrl));
|
|
56
64
|
// `--import` takes a URL. Converting the resolved loader to a path made Node on
|
|
57
65
|
// Windows reject it ("Received protocol 'c:'"), so every verification launched
|
|
58
66
|
// from a source checkout there failed before running and cards showed no check.
|
|
59
|
-
const loader =
|
|
60
|
-
const argv = [process.execPath, ...(
|
|
67
|
+
const loader = dev ? resolve("tsx") : null;
|
|
68
|
+
const argv = [process.execPath, ...(loader ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
|
|
61
69
|
const quote = (s) => process.platform === "win32" ? `'${s.replace(/'/g, "''")}'` : `'${s.replace(/'/g, "'\\''")}'`;
|
|
62
70
|
return { argv, shell: `${process.platform === "win32" ? "& " : ""}${argv.map(quote).join(" ")}` };
|
|
63
71
|
}
|
|
@@ -91,7 +99,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
|
|
|
91
99
|
task = readTaskReport(root, task_id, reportSourceSnapshot(root).hash).task;
|
|
92
100
|
}
|
|
93
101
|
const launcher = verificationLauncher();
|
|
94
|
-
return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]. The default budget is
|
|
102
|
+
return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]. The default budget is 15 minutes; add --timeout <seconds> before -- for a longer suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
|
|
95
103
|
}
|
|
96
104
|
if (!task_id)
|
|
97
105
|
throw new Error("finish requires the exact task_id");
|
package/dist/taskReports.d.ts
CHANGED
|
@@ -21,6 +21,10 @@ export declare function createTaskReporter(root: string): {
|
|
|
21
21
|
started_at: string;
|
|
22
22
|
finished_at: string | null;
|
|
23
23
|
state: "open" | "completed" | "interrupted";
|
|
24
|
+
closed_by?: "host" | "agent" | undefined;
|
|
25
|
+
session_key?: string | undefined;
|
|
26
|
+
continues?: string | undefined;
|
|
27
|
+
episode?: string | undefined;
|
|
24
28
|
};
|
|
25
29
|
/** The caller supplies the exact envelope it issued plus snapshots of the
|
|
26
30
|
* included revisions. Return the occurrence with the context to the agent.
|
|
@@ -69,6 +73,10 @@ export declare function createTaskReporter(root: string): {
|
|
|
69
73
|
started_at: string;
|
|
70
74
|
finished_at: string | null;
|
|
71
75
|
state: "open" | "completed" | "interrupted";
|
|
76
|
+
closed_by?: "host" | "agent" | undefined;
|
|
77
|
+
session_key?: string | undefined;
|
|
78
|
+
continues?: string | undefined;
|
|
79
|
+
episode?: string | undefined;
|
|
72
80
|
}[];
|
|
73
81
|
lesson: (reference: LessonReference, options?: {
|
|
74
82
|
limit?: number;
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.38.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.
|
|
16
|
+
"version": "1.38.1",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|