@davesheffer/hunch 1.37.0 → 1.38.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/dist/cli/index.js CHANGED
@@ -68,6 +68,7 @@ import { formatSearchHit, formatStructure } from "../core/format.js";
68
68
  import { isStateKind, renderStateLine, stateSupplements } from "../core/stateDelivery.js";
69
69
  import { taskSelectionSupplements } from "../core/taskDelivery.js";
70
70
  import { buildTaskRankingQuery } from "../core/taskQuery.js";
71
+ import { rankingStatusLine, resolveTaskRankingMode } from "../core/taskRankingMode.js";
71
72
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
72
73
  import { compileVerifiedEvidenceMap, formatVerifiedEvidenceMap } from "../core/evidenceMap.js";
73
74
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
@@ -87,7 +88,8 @@ import { recordServed, servedSummary } from "../core/served.js";
87
88
  import { recordTaskDelivery, reportActivity, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
88
89
  import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
89
90
  import { renderRecalledLine } from "../core/taskReportRender.js";
90
- import { hookReportTaskId, nativeHookCwd, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
91
+ import { closeHookTask, hookReportTaskId, nativeHookCwd, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
92
+ import { persistTaskRecord } from "../core/taskRecord.js";
91
93
  import { recordHookObservation } from "../core/hookObservations.js";
92
94
  import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
93
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";
@@ -4212,7 +4214,7 @@ program
4212
4214
  decisionCorpus: store.recs("decisions"),
4213
4215
  historical: !!asOf,
4214
4216
  profile: opts.profile,
4215
- supplements: [...stateGrounding, ...(asOf ? [] : taskSelectionSupplements(store.selectTasksFor(target, buildTaskRankingQuery(root, opts.task ?? null, target)), target))],
4217
+ supplements: [...stateGrounding, ...(asOf ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, opts.task ?? null, target)), target))],
4216
4218
  });
4217
4219
  process.stdout.write(envelope.text);
4218
4220
  if (opts.task) {
@@ -4547,6 +4549,16 @@ program
4547
4549
  return;
4548
4550
  }
4549
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) {
4557
+ store ??= new HunchStore(paths);
4558
+ persistTaskRecord(root, store, closed);
4559
+ }
4560
+ }
4561
+ catch { /* the ledger and the card remain authoritative; the next finish retries */ }
4550
4562
  const report = stopHookReport(root, provider, evt);
4551
4563
  if (report)
4552
4564
  console.log(JSON.stringify(report));
@@ -4882,7 +4894,7 @@ program
4882
4894
  // from this file. No diff exists yet, so this is context — "don't re-add X" —
4883
4895
  // not a block; the commit-time `hunch check` does the actual gating.
4884
4896
  const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
4885
- const recentTasks = taskSelectionSupplements(store.selectTasksFor(target, buildTaskRankingQuery(root, hookReportTaskId(root, provider, evt), target)), target);
4897
+ const recentTasks = taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, hookReportTaskId(root, provider, evt), target)), target);
4886
4898
  const hasContent = ctx.constraints.length ||
4887
4899
  ctx.decisions.length ||
4888
4900
  ctx.bugs.length ||
@@ -6473,6 +6485,11 @@ program
6473
6485
  console.log(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
6474
6486
  if (pendingReview > 0)
6475
6487
  console.log(`\n (${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` to auto-trust them as advisory)`);
6488
+ // Task-record ranking: evaluated automatically on every task write; the kill rule applies itself.
6489
+ try {
6490
+ console.log(`\n📊 ${rankingStatusLine(resolveTaskRankingMode(store.publicRoot, store))}`);
6491
+ }
6492
+ catch { /* no task records or no cache dir: nothing to say */ }
6476
6493
  }
6477
6494
  finally {
6478
6495
  store.close();
@@ -9,6 +9,7 @@ import { assertReportPath } from "../core/taskReportPaths.js";
9
9
  import { publicTaskReport } from "../core/taskReportPublic.js";
10
10
  import { mergeDurableTaskSummaries, persistTaskRecord } from "../core/taskRecord.js";
11
11
  import { evaluateTaskRanking, renderRankEval } from "../core/taskRankEval.js";
12
+ import { rankingStatusLine, refreshRankEval, resolveTaskRankingMode } from "../core/taskRankingMode.js";
12
13
  import { taskRecordStats } from "../core/taskRecordStats.js";
13
14
  export function registerTaskReportCommands(program, openStore) {
14
15
  const task = program.command("task").description("Record an explicit task lifecycle for Hunch contribution reports");
@@ -89,7 +90,7 @@ export function registerTaskReportCommands(program, openStore) {
89
90
  return;
90
91
  }
91
92
  for (const s of summaries)
92
- 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})` : ""}`);
93
94
  });
94
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")
95
96
  .option("--days <days>", "window in days", "7")
@@ -117,6 +118,7 @@ export function registerTaskReportCommands(program, openStore) {
117
118
  console.log(`Graph task records: ${rs.records}`);
118
119
  console.log(` re-verified an earlier check (24h) ${rate(rs.reverification_rate, rs.reverify_candidates)}`);
119
120
  console.log(` repeated an earlier violation ${rate(rs.repeat_violation_rate, rs.violation_candidates)}`);
121
+ console.log(` ${rankingStatusLine(resolveTaskRankingMode(opened.root, opened.store))}`);
120
122
  }
121
123
  finally {
122
124
  opened.store.close();
@@ -134,6 +136,8 @@ export function registerTaskReportCommands(program, openStore) {
134
136
  const cutoff = Date.now() - (Number(opts.since) || 365) * 86_400_000;
135
137
  const records = store.recs("tasks").filter((r) => (Date.parse(r.finished_at) || 0) >= cutoff);
136
138
  const report = evaluateTaskRanking(records, { split: Math.min(1, Math.max(0.05, Number(opts.split) || 0.3)) });
139
+ // Keep the automatic cache current too, so delivery and `hunch now` agree with what was just printed.
140
+ refreshRankEval(findRoot(), store, { force: true });
137
141
  console.log(opts.json ? JSON.stringify(report, null, 2) : renderRankEval(report));
138
142
  }
139
143
  finally {
@@ -275,11 +275,11 @@ export declare const EvidenceEventSchema: z.ZodObject<{
275
275
  }, z.core.$strict>>;
276
276
  compiler: z.ZodOptional<z.ZodObject<{
277
277
  status: z.ZodEnum<{
278
- conflicted: "conflicted";
279
- eligible: "eligible";
280
278
  compiled: "compiled";
279
+ eligible: "eligible";
281
280
  covered: "covered";
282
281
  uncompilable: "uncompilable";
282
+ conflicted: "conflicted";
283
283
  }>;
284
284
  policy: z.ZodDefault<z.ZodNullable<z.ZodString>>;
285
285
  reason: z.ZodDefault<z.ZodString>;
@@ -10,9 +10,9 @@ export declare const CompilerCaseBankSchema: z.ZodObject<{
10
10
  evidence: z.ZodString;
11
11
  expected: z.ZodObject<{
12
12
  outcome: z.ZodEnum<{
13
- conflicted: "conflicted";
14
13
  covered: "covered";
15
14
  uncompilable: "uncompilable";
15
+ conflicted: "conflicted";
16
16
  assertion: "assertion";
17
17
  }>;
18
18
  assertion: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -97,9 +97,9 @@ export declare const CompilerCaseBankSchema: z.ZodObject<{
97
97
  }, z.core.$strict>;
98
98
  actual: z.ZodObject<{
99
99
  outcome: z.ZodEnum<{
100
- conflicted: "conflicted";
101
100
  covered: "covered";
102
101
  uncompilable: "uncompilable";
102
+ conflicted: "conflicted";
103
103
  assertion: "assertion";
104
104
  }>;
105
105
  assertion: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -59,8 +59,8 @@ export declare const UsefulnessObservationSchema: z.ZodObject<{
59
59
  kind: z.ZodEnum<{
60
60
  observation: "observation";
61
61
  artifact: "artifact";
62
- receipt: "receipt";
63
62
  event: "event";
63
+ receipt: "receipt";
64
64
  verification: "verification";
65
65
  }>;
66
66
  ref: z.ZodString;
@@ -92,8 +92,8 @@ export declare const CaptureRequestSchema: z.ZodObject<{
92
92
  relevance: z.ZodObject<{
93
93
  use: z.ZodEnum<{
94
94
  decision: "decision";
95
- constraint: "constraint";
96
95
  preference: "preference";
96
+ constraint: "constraint";
97
97
  operational_fact: "operational_fact";
98
98
  ongoing_issue: "ongoing_issue";
99
99
  }>;
@@ -160,8 +160,8 @@ export declare const CaptureBatchRequestSchema: z.ZodObject<{
160
160
  relevance: z.ZodObject<{
161
161
  use: z.ZodEnum<{
162
162
  decision: "decision";
163
- constraint: "constraint";
164
163
  preference: "preference";
164
+ constraint: "constraint";
165
165
  operational_fact: "operational_fact";
166
166
  ongoing_issue: "ongoing_issue";
167
167
  }>;
@@ -255,8 +255,8 @@ export declare const ReadRequestSchema: z.ZodObject<{
255
255
  subject: z.ZodOptional<z.ZodString>;
256
256
  task: z.ZodOptional<z.ZodString>;
257
257
  profile: z.ZodOptional<z.ZodEnum<{
258
- builder: "builder";
259
258
  reviewer: "reviewer";
259
+ builder: "builder";
260
260
  architect: "architect";
261
261
  }>>;
262
262
  budget_tokens: z.ZodOptional<z.ZodNumber>;
@@ -36,7 +36,9 @@ export function taskSelectionSupplements(selection, target) {
36
36
  const counts = { latest: 0, violation: 0, relevant: 0 };
37
37
  for (const p of selection.picks)
38
38
  counts[p.slot]++;
39
- const parts = [counts.latest ? "latest" : null, counts.violation ? "problem" : null, counts.relevant ? `relevant ${counts.relevant}` : null].filter(Boolean).join(" · ");
39
+ const parts = selection.mode === "latest"
40
+ ? `latest ${counts.latest} (ranking off: it lost its evaluation; hunch task rank-eval)`
41
+ : [counts.latest ? "latest" : null, counts.violation ? "problem" : null, counts.relevant ? `relevant ${counts.relevant}` : null].filter(Boolean).join(" · ");
40
42
  return [
41
43
  {
42
44
  id: "recent-tasks", kind: "recent-tasks", priority: 415,
@@ -90,7 +90,12 @@ export interface TaskSelection {
90
90
  /** Ranked candidates not shown. */
91
91
  more: number;
92
92
  candidates: number;
93
+ /** How the picks were chosen: the ranker, or the "latest 3" fallback the kill rule imposes. */
94
+ mode?: "ranked" | "latest";
93
95
  }
96
+ /** The pre-ranking behaviour, kept as the baseline and the fallback: the three
97
+ * most recent records on the exact file, no scoring. */
98
+ export declare function selectLatestTasks(records: readonly TaskRecord[], query: RankingQuery, ctx: RankingContext, limit?: number): TaskSelection;
94
99
  export interface SlotOptions {
95
100
  limit?: number;
96
101
  relevant?: number;
@@ -9,6 +9,18 @@ export const COCHANGE_MIN_COUNT = 2;
9
9
  * at most half of all task records (idf ≥ ln 2). A rule every task receives
10
10
  * says nothing about relatedness; it still contributes to the score, weakly. */
11
11
  export const RULE_GATE_MIN_IDF = Math.log(2);
12
+ /** The pre-ranking behaviour, kept as the baseline and the fallback: the three
13
+ * most recent records on the exact file, no scoring. */
14
+ export function selectLatestTasks(records, query, ctx, limit = 3) {
15
+ const target = normalizePath(query.target);
16
+ const ranked = records
17
+ .filter((r) => !ctx.superseded?.has(r.id) && recordFiles(r).includes(target))
18
+ .map((r) => rankTaskRecord(r, query, ctx))
19
+ .filter((r) => r !== null)
20
+ .sort(newestFirst);
21
+ const picks = ranked.slice(0, Math.max(1, limit)).map((r) => ({ slot: "latest", ranked: r }));
22
+ return { picks, more: ranked.length - picks.length, candidates: ranked.length, mode: "latest" };
23
+ }
12
24
  const DAY_MS = 86_400_000;
13
25
  export function normalizePath(path) {
14
26
  return path.trim().replace(/\\/g, "/").replace(/^\.\//, "");
@@ -197,6 +209,6 @@ export function selectTaskSlots(ranked, options = {}) {
197
209
  take("relevant", best);
198
210
  remaining = remaining.filter((r) => r !== best);
199
211
  }
200
- return { picks, more: ranked.length - picks.length, candidates: ranked.length };
212
+ return { picks, more: ranked.length - picks.length, candidates: ranked.length, mode: "ranked" };
201
213
  }
202
214
  //# sourceMappingURL=taskRanking.js.map
@@ -0,0 +1,36 @@
1
+ import type { HunchStore } from "../store/hunchStore.js";
2
+ import { type RankEvalReport } from "./taskRankEval.js";
3
+ export declare const RANK_EVAL_CACHE_SCHEMA: "hunch.task-rank-eval-cache/1";
4
+ /** The decision's threshold: the kill rule needs at least this many task records. */
5
+ export declare const KILL_MIN_TASKS = 200;
6
+ export type TaskRankingMode = "ranked" | "latest";
7
+ export interface RankEvalCache {
8
+ schema: typeof RANK_EVAL_CACHE_SCHEMA;
9
+ computed_at: string;
10
+ /** Task-record count the report was computed over; a different count triggers a recompute. */
11
+ records: number;
12
+ /** Content hash of the record ids + report hashes, so an edit without a count change also refreshes. */
13
+ corpus_hash: string;
14
+ report: RankEvalReport;
15
+ }
16
+ export declare function readRankEvalCache(root: string): RankEvalCache | null;
17
+ /** Recompute when the corpus changed; otherwise return the cached report. Never throws. */
18
+ export declare function refreshRankEval(root: string, store: HunchStore, options?: {
19
+ force?: boolean;
20
+ now?: () => string;
21
+ }): RankEvalCache | null;
22
+ /** The rule from the decision, applied to a report. */
23
+ export declare function modeFromReport(report: RankEvalReport | null | undefined): {
24
+ mode: TaskRankingMode;
25
+ reason: string;
26
+ };
27
+ export interface ResolvedRankingMode {
28
+ mode: TaskRankingMode;
29
+ reason: string;
30
+ source: "override" | "auto";
31
+ cache: RankEvalCache | null;
32
+ }
33
+ /** What delivery should do right now for this repository. */
34
+ export declare function resolveTaskRankingMode(root: string, store: HunchStore): ResolvedRankingMode;
35
+ /** One line for `hunch now` and the task stats. */
36
+ export declare function rankingStatusLine(resolved: ResolvedRankingMode): string;
@@ -0,0 +1,122 @@
1
+ /** Automatic evaluation and the automatic kill rule for task-record ranking
2
+ * (dec_66925aa0ee). Nobody has to run anything:
3
+ *
4
+ * - every time a task record is written, the leave-one-out evaluation is
5
+ * recomputed over the graph's task records (pure, a few hundred records)
6
+ * and cached under .hunch-cache — derived state, safe to delete;
7
+ * - delivery reads the cache to pick its mode: `ranked` until the
8
+ * pre-registered rule says otherwise, `latest` (three most recent on the
9
+ * file) once the ranked selection has LOST to the baseline with a
10
+ * confidence interval excluding zero over at least KILL_MIN_TASKS records;
11
+ * - `hunch now` prints the current line; a verdict change becomes a finding.
12
+ *
13
+ * `.hunch/local.json` `taskRanking: "ranked" | "latest"` overrides the automatic
14
+ * choice for a repository that wants to pin it. */
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { flushCapture } from "../integrations/sync.js";
18
+ import { writeFileAtomic } from "./io.js";
19
+ import { hunchPaths } from "./paths.js";
20
+ import { evaluateTaskRanking } from "./taskRankEval.js";
21
+ import { reportHash } from "./taskReport.js";
22
+ import { FindingSchema } from "./types.js";
23
+ export const RANK_EVAL_CACHE_SCHEMA = "hunch.task-rank-eval-cache/1";
24
+ /** The decision's threshold: the kill rule needs at least this many task records. */
25
+ export const KILL_MIN_TASKS = 200;
26
+ function cachePath(root) {
27
+ return join(root, ".hunch-cache", "task-rank-eval.json");
28
+ }
29
+ export function readRankEvalCache(root) {
30
+ try {
31
+ const parsed = JSON.parse(readFileSync(cachePath(root), "utf8"));
32
+ return parsed && parsed.schema === RANK_EVAL_CACHE_SCHEMA && parsed.report ? parsed : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ function corpusHash(store) {
39
+ const records = store.recs("tasks");
40
+ const ids = records.map((r) => `${r.id}:${r.report_hash}`).sort();
41
+ return { hash: reportHash(ids), count: records.length };
42
+ }
43
+ /** Recompute when the corpus changed; otherwise return the cached report. Never throws. */
44
+ export function refreshRankEval(root, store, options = {}) {
45
+ try {
46
+ const { hash, count } = corpusHash(store);
47
+ const cached = readRankEvalCache(root);
48
+ if (!options.force && cached && cached.corpus_hash === hash)
49
+ return cached;
50
+ const report = evaluateTaskRanking(store.recs("tasks"));
51
+ const next = { schema: RANK_EVAL_CACHE_SCHEMA, computed_at: (options.now ?? (() => new Date().toISOString()))(), records: count, corpus_hash: hash, report };
52
+ try {
53
+ const dir = join(root, ".hunch-cache");
54
+ if (!existsSync(dir))
55
+ return next; // no cache dir: still return the fresh report, just do not persist
56
+ writeFileAtomic(cachePath(root), JSON.stringify(next, null, 2));
57
+ }
58
+ catch { /* cache is a convenience */ }
59
+ if (cached && cached.report.verdict !== report.verdict)
60
+ noteVerdictChange(root, store, cached, next);
61
+ return next;
62
+ }
63
+ catch {
64
+ return readRankEvalCache(root);
65
+ }
66
+ }
67
+ /** The rule from the decision, applied to a report. */
68
+ export function modeFromReport(report) {
69
+ if (!report)
70
+ return { mode: "ranked", reason: "no evaluation yet" };
71
+ if (report.verdict === "baseline-better" && report.tasks >= KILL_MIN_TASKS) {
72
+ return { mode: "latest", reason: `ranked selection lost to latest3 on ${report.split.evaluated} cases (Hit@5 ${pct(report.rankers[0]?.hit5)} vs ${pct(report.rankers[1]?.hit5)}, CI [${pts(report.delta_hit5.ci95[0])}, ${pts(report.delta_hit5.ci95[1])}])` };
73
+ }
74
+ return { mode: "ranked", reason: report.verdict === "ranked-better" ? "ranked selection beats latest3" : report.verdict === "baseline-better" ? `latest3 ahead but only ${report.tasks} of ${KILL_MIN_TASKS} task records; not yet decisive` : `evaluation ${report.verdict} (${report.split.evaluated} cases)` };
75
+ }
76
+ /** What delivery should do right now for this repository. */
77
+ export function resolveTaskRankingMode(root, store) {
78
+ let override;
79
+ try {
80
+ override = JSON.parse(readFileSync(join(root, ".hunch", "local.json"), "utf8")).taskRanking;
81
+ }
82
+ catch { /* none */ }
83
+ const cache = refreshRankEval(root, store);
84
+ if (override === "ranked" || override === "latest")
85
+ return { mode: override, reason: `pinned by .hunch/local.json taskRanking`, source: "override", cache };
86
+ const { mode, reason } = modeFromReport(cache?.report);
87
+ return { mode, reason, source: "auto", cache };
88
+ }
89
+ function pct(x) { return x === undefined ? "–" : `${Math.round(x * 100)}%`; }
90
+ function pts(x) { return `${x >= 0 ? "+" : ""}${Math.round(x * 100)}`; }
91
+ /** One line for `hunch now` and the task stats. */
92
+ export function rankingStatusLine(resolved) {
93
+ const r = resolved.cache?.report;
94
+ if (!r)
95
+ return `task ranking: ${resolved.mode} · no evaluation yet (no finished task records)`;
96
+ const ranked = r.rankers.find((x) => x.name === "ranked"), base = r.rankers.find((x) => x.name === "latest3");
97
+ return `task ranking: ${resolved.mode}${resolved.source === "override" ? " (pinned)" : ""} · Hit@5 ranked ${pct(ranked?.hit5)} vs latest3 ${pct(base?.hit5)}, CI [${pts(r.delta_hit5.ci95[0])}, ${pts(r.delta_hit5.ci95[1])}], n=${r.split.evaluated} of ${r.tasks} records · ${r.verdict}${r.tasks < KILL_MIN_TASKS ? ` · kill rule armed at ${KILL_MIN_TASKS} records` : ""}`;
98
+ }
99
+ /** A verdict change is memory: record it once, through the normal capture path. */
100
+ function noteVerdictChange(root, store, prev, next) {
101
+ try {
102
+ const title = `Task ranking evaluation verdict changed: ${prev.report.verdict} → ${next.report.verdict} at ${next.records} task records`;
103
+ const id = `fnd_${reportHash(title).slice(7, 17)}`;
104
+ if (store.getRec("findings", id))
105
+ return;
106
+ const r = next.report;
107
+ const finding = FindingSchema.parse({
108
+ id, title,
109
+ observation: `Automatic leave-one-out evaluation of task-record ranking (dec_66925aa0ee) recomputed after a task record was written. Ranked Hit@5 ${pct(r.rankers[0]?.hit5)} vs latest3 ${pct(r.rankers[1]?.hit5)}, MRR ${r.rankers[0]?.mrr.toFixed(2)} vs ${r.rankers[1]?.mrr.toFixed(2)}, paired bootstrap CI on Hit@5 [${pts(r.delta_hit5.ci95[0])}, ${pts(r.delta_hit5.ci95[1])}] over ${r.split.evaluated} cases (${r.evaluable} evaluable of ${r.tasks}). Delivery mode now: ${modeFromReport(r).mode} — ${modeFromReport(r).reason}.`,
110
+ evidence: [`hunch task rank-eval --json (computed ${next.computed_at})`, `.hunch-cache/task-rank-eval.json corpus ${next.corpus_hash}`],
111
+ severity: next.report.verdict === "baseline-better" ? "high" : "low",
112
+ triage: "open",
113
+ affected_files: ["src/core/taskRanking.ts", "src/core/taskRankingMode.ts"],
114
+ observed_at: next.computed_at,
115
+ provenance: { source: "task_rank_eval", confidence: 0.9, evidence: ["hunch task rank-eval"], last_verified: next.computed_at },
116
+ });
117
+ store.putCapture("findings", finding, false);
118
+ flushCapture(store, hunchPaths(root).hunch, false, `hunch: capture ${id}`);
119
+ }
120
+ catch { /* the cache still carries the verdict; memory of the change is best-effort */ }
121
+ }
122
+ //# sourceMappingURL=taskRankingMode.js.map
@@ -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
- export declare function taskRecordFromReport(report: TaskReport): TaskRecord | null;
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. */
@@ -14,9 +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 } from "./taskReport.js";
18
18
  import { reportSourceSnapshot } from "./taskReportEvidence.js";
19
19
  import { ENTITY_KINDS, TaskRecordSchema } from "./types.js";
20
+ import { refreshRankEval } from "./taskRankingMode.js";
21
+ import { gitTouchedFiles } from "./taskTouched.js";
20
22
  function localConfig(root) {
21
23
  try {
22
24
  return JSON.parse(readFileSync(join(root, ".hunch", "local.json"), "utf8"));
@@ -46,50 +48,76 @@ export function targetLooksLikePath(target) {
46
48
  return false;
47
49
  return /[./\\]/.test(t) && !/^\.+$/.test(t);
48
50
  }
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))
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)))
55
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;
56
72
  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
73
  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
74
  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 });
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;
74
100
  }
75
- const lastCheck = report.checks.at(-1);
101
+ const fromReport = [...files].sort();
102
+ const extra = [...new Set(touched.map((f) => f.trim().replace(/\\/g, "/")).filter((f) => f && !files.has(f)))].sort();
76
103
  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,
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,
83
110
  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 })),
111
+ applied,
112
+ saved,
113
+ checks: checks.slice(-64),
87
114
  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 },
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 },
93
121
  });
94
122
  }
95
123
  /** Where the record belongs. Anything that touched the private overlay — a
@@ -144,13 +172,35 @@ export function computeSupersedes(record, others, limit = 20) {
144
172
  export function persistTaskRecord(root, store, taskId, options = {}) {
145
173
  if (!taskRecordsEnabled(root))
146
174
  return null;
147
- const report = readTaskReport(root, taskId, reportSourceSnapshot(root).hash);
148
- const built = taskRecordFromReport(report);
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
+ let built = taskRecordFromReports(reports, gitTouchedFiles(root, window.from, window.to));
149
188
  if (!built)
150
189
  return null;
190
+ let inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
191
+ let inPublic = store.json.get("tasks", built.id);
192
+ // A record never changes home. If the episode's record already lives in the
193
+ // public store and this prompt brought private-only memory into it, the
194
+ // episode splits here: this prompt keeps its own record instead of naming
195
+ // private memory in a public one.
196
+ if (inPublic && !inPrivate && taskRecordHome(store, built) === "private" && built.id !== taskId) {
197
+ built = taskRecordFromReports([own], gitTouchedFiles(root, own.task.started_at, own.task.finished_at));
198
+ if (!built)
199
+ return null;
200
+ inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
201
+ inPublic = store.json.get("tasks", built.id);
202
+ }
151
203
  const record = { ...built, supersedes: computeSupersedes(built, store.recs("tasks")) };
152
- const inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", record.id) : undefined;
153
- const inPublic = store.json.get("tasks", record.id);
154
204
  const home = inPrivate ? "private" : inPublic ? "public" : taskRecordHome(store, record);
155
205
  const existing = home === "private" ? inPrivate : inPublic;
156
206
  if (existing && existing.report_hash === record.report_hash)
@@ -159,6 +209,8 @@ export function persistTaskRecord(root, store, taskId, options = {}) {
159
209
  store.reindex();
160
210
  const flushNow = options.flush ?? taskRecordFlushMode(root) === "each";
161
211
  const flushed = flushNow ? flushCapture(store, hunchPaths(root).hunch, home === "private", `hunch: task ${record.id}`) : null;
212
+ // The ranking evaluation follows the corpus: recomputed here, read at delivery. Never blocks a finish.
213
+ refreshRankEval(root, store);
162
214
  return { record: stored, home, flushed, changed: true };
163
215
  }
164
216
  const GRAPH_SCOPE = reportHash("graph-record");
@@ -199,7 +251,10 @@ export function mergeDurableTaskSummaries(store, summaries, limit = 30) {
199
251
  const seen = new Set();
200
252
  const merged = summaries.map((s) => {
201
253
  seen.add(s.task.task_id);
202
- const home = homes.get(s.task.task_id);
254
+ // A continued prompt's record lives under its episode head.
255
+ const home = homes.get(s.task.task_id) ?? homes.get(s.task.episode ?? "");
256
+ if (s.task.episode)
257
+ seen.add(s.task.episode);
203
258
  return { ...s, durable: home ? { home } : null };
204
259
  });
205
260
  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 {
@@ -200,7 +207,26 @@ export declare function readLessonHistory(root: string, reference: LessonReferen
200
207
  limit?: number;
201
208
  before?: number;
202
209
  }): LessonHistory;
203
- export declare function startReportTask(root: string, title: string, taskId?: string): ReportTask;
210
+ export interface TaskLinks {
211
+ session_key?: string;
212
+ continues?: string;
213
+ episode?: string;
214
+ }
215
+ export declare function startReportTask(root: string, title: string, taskId?: string, links?: TaskLinks): ReportTask;
216
+ /** A prompt that follows another in the same session within this window is the
217
+ * same work: its task continues the previous one and shares its episode. */
218
+ export declare const CONTINUATION_WINDOW_MS: number;
219
+ /** The links a new prompt's task takes from the latest task of its session, or
220
+ * null when that task is too old (measured from its close, or its start when it
221
+ * was never closed) to be the same work. */
222
+ export declare function continuationLinks(previous: ReportTask | null, nowMs?: number): {
223
+ continues: string;
224
+ episode: string;
225
+ } | null;
226
+ /** The most recent task of a session in this worktree, or null. */
227
+ export declare function latestSessionTask(root: string, sessionKey: string): ReportTask | null;
228
+ /** Every task of an episode, oldest first: the head and the prompts that continued it. */
229
+ export declare function episodeTasks(root: string, headId: string): ReportTask[];
204
230
  /** The record revisions among `records` that this task has not received before.
205
231
  * Powers the one-line "Hunch recalled …" indication on a lesson's FIRST delivery
206
232
  * in a task; repeats of the same revision stay silent (deduplicated per task and
@@ -223,8 +249,21 @@ export declare function recordReportRefusal(root: string, taskId: string, refusa
223
249
  export declare function recordReportConformance(root: string, taskId: string, conformance: ReportConformance): string;
224
250
  /** Only the local runner calls this. MCP never accepts a claimed successful check. */
225
251
  export declare function recordReportCheck(root: string, taskId: string, check: ReportCheck): string;
226
- export declare function beginReportCheck(root: string, taskId: string, label: string): string;
227
- export declare function finishReportTask(root: string, taskId: string, state?: "completed" | "interrupted"): ReportTask;
252
+ /** A start without a result blocks completion only while the runner could still
253
+ * deliver one: its own timeout plus a minute of grace. After that the runner is
254
+ * gone (a killed process, a closed laptop) and the report's unknowns already say
255
+ * the result was not retained; freezing the task forever would add nothing.
256
+ * Starts recorded before the timeout was retained use the verification ceiling. */
257
+ export declare const CHECK_RESULT_GRACE_MS = 60000;
258
+ export declare const MAX_PENDING_CHECK_MS: number;
259
+ export declare function beginReportCheck(root: string, taskId: string, label: string, timeoutMs?: number): string;
260
+ /** `by: "host"` is the lifecycle hook closing the prompt's task at Stop. It is
261
+ * provisional: a later observation reopens the task (see appendEvent) and an
262
+ * explicit agent finish, with any outcome, replaces it. Pending verification
263
+ * keeps the task open for either closer. */
264
+ export declare function finishReportTask(root: string, taskId: string, state?: "completed" | "interrupted", options?: {
265
+ by?: "agent" | "host";
266
+ }): ReportTask;
228
267
  /** A report with no observation of any kind. Presentation surfaces may stay
229
268
  * silent for it; the task row itself is retained so "never touched Hunch" is
230
269
  * still countable (hunch report / the VS Code view / task list). */
@@ -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(),
@@ -227,9 +240,9 @@ function transaction(db, run, readOnly = false) {
227
240
  throw error;
228
241
  }
229
242
  }
230
- export function startReportTask(root, title, taskId) {
243
+ export function startReportTask(root, title, taskId, links = {}) {
231
244
  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" });
245
+ scope: scopeOf(root), title, started_at: new Date().toISOString(), finished_at: null, state: "open", ...links });
233
246
  // Local observations have a bounded lifetime; durable project memory is untouched.
234
247
  pruneReportHistory(root);
235
248
  return taskDb(root, db => transaction(db, () => {
@@ -244,6 +257,31 @@ export function startReportTask(root, title, taskId) {
244
257
  return task;
245
258
  }));
246
259
  }
260
+ /** A prompt that follows another in the same session within this window is the
261
+ * same work: its task continues the previous one and shares its episode. */
262
+ export const CONTINUATION_WINDOW_MS = 30 * 60_000;
263
+ /** The links a new prompt's task takes from the latest task of its session, or
264
+ * null when that task is too old (measured from its close, or its start when it
265
+ * was never closed) to be the same work. */
266
+ export function continuationLinks(previous, nowMs = Date.now()) {
267
+ if (!previous)
268
+ return null;
269
+ const reference = Date.parse(previous.finished_at ?? previous.started_at);
270
+ if (!Number.isFinite(reference) || nowMs - reference > CONTINUATION_WINDOW_MS)
271
+ return null;
272
+ return { continues: previous.task_id, episode: previous.episode ?? previous.task_id };
273
+ }
274
+ /** The most recent task of a session in this worktree, or null. */
275
+ export function latestSessionTask(root, sessionKey) {
276
+ return taskDb(root, db => {
277
+ 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);
278
+ return row ? TaskSchema.parse(JSON.parse(row.body)) : null;
279
+ });
280
+ }
281
+ /** Every task of an episode, oldest first: the head and the prompts that continued it. */
282
+ export function episodeTasks(root, headId) {
283
+ 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))));
284
+ }
247
285
  function appendEvent(root, taskId, kind, body, eventId) {
248
286
  const encoded = JSON.stringify(body);
249
287
  if (Buffer.byteLength(encoded) > MAX_EVENT_BYTES)
@@ -260,8 +298,14 @@ function appendEvent(root, taskId, kind, body, eventId) {
260
298
  throw new Error("report event identity conflicts with existing evidence");
261
299
  return id;
262
300
  }
263
- if (task.state !== "open" && !(kind === "check" && task.state === "interrupted"))
264
- throw new Error("task is already closed; start a new task for new work");
301
+ if (task.state !== "open" && !(kind === "check" && task.state === "interrupted")) {
302
+ if (task.closed_by !== "host")
303
+ throw new Error("task is already closed; start a new task for new work");
304
+ // The host closed this task at Stop, but the turn went on (another hook's
305
+ // block, a resumed prompt). Reopen it for the new observation; the next
306
+ // Stop closes it again and the graph record is refreshed from the report.
307
+ db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(TaskSchema.parse({ ...task, state: "open", finished_at: null, closed_by: undefined })), taskId);
308
+ }
265
309
  if (kind === "check") {
266
310
  const check = ReportCheckSchema.parse(body);
267
311
  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, taskId))
@@ -269,7 +313,7 @@ function appendEvent(root, taskId, kind, body, eventId) {
269
313
  }
270
314
  const { total, bytes, pending } = db.prepare(`SELECT COUNT(*) AS total,
271
315
  COALESCE(SUM(length(CAST(body AS BLOB))), 0) AS bytes,
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
316
+ 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
273
317
  FROM report_events WHERE task_id = ?`).get(taskId);
274
318
  const reserved = Math.max(0, (pending ?? 0) + (kind === "check-start" ? 1 : kind === "check" ? -1 : 0));
275
319
  if (total + 1 + reserved > MAX_EVENTS || bytes + Buffer.byteLength(encoded) + reserved * MAX_EVENT_BYTES > MAX_TASK_BYTES)
@@ -361,23 +405,41 @@ export function recordReportCheck(root, taskId, check) {
361
405
  throw new Error("verification result requires a reserved check identity");
362
406
  return appendEvent(root, taskId, "check", value, `hev_${reportHash({ taskId, check: value.check_id }).slice(7, 31)}`);
363
407
  }
364
- export function beginReportCheck(root, taskId, label) {
365
- return appendEvent(root, taskId, "check-start", { label: safeText(200).parse(label) });
408
+ /** A start without a result blocks completion only while the runner could still
409
+ * deliver one: its own timeout plus a minute of grace. After that the runner is
410
+ * gone (a killed process, a closed laptop) and the report's unknowns already say
411
+ * the result was not retained; freezing the task forever would add nothing.
412
+ * Starts recorded before the timeout was retained use the verification ceiling. */
413
+ export const CHECK_RESULT_GRACE_MS = 60_000;
414
+ export const MAX_PENDING_CHECK_MS = 6 * 60 * 60_000;
415
+ export function beginReportCheck(root, taskId, label, timeoutMs) {
416
+ const timeout = Number.isInteger(timeoutMs) && timeoutMs > 0 ? { timeout_ms: timeoutMs } : {};
417
+ return appendEvent(root, taskId, "check-start", { label: safeText(200).parse(label), ...timeout });
366
418
  }
367
- export function finishReportTask(root, taskId, state = "completed") {
419
+ /** `by: "host"` is the lifecycle hook closing the prompt's task at Stop. It is
420
+ * provisional: a later observation reopens the task (see appendEvent) and an
421
+ * explicit agent finish, with any outcome, replaces it. Pending verification
422
+ * keeps the task open for either closer. */
423
+ export function finishReportTask(root, taskId, state = "completed", options = {}) {
424
+ const by = options.by ?? "agent";
368
425
  return taskDb(root, db => transaction(db, () => {
369
426
  const task = readTask(db, root, taskId);
370
427
  if (task.state !== "open") {
428
+ if (task.closed_by === "host" && by === "agent") {
429
+ const confirmed = TaskSchema.parse({ ...task, state, finished_at: task.state === state ? task.finished_at : new Date().toISOString(), closed_by: "agent" });
430
+ db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(confirmed), taskId);
431
+ return confirmed;
432
+ }
371
433
  if (task.state !== state)
372
434
  throw new Error("task already closed with a different outcome");
373
435
  return task;
374
436
  }
375
437
  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);
438
+ 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
439
  if ((pending ?? 0) > 0)
378
440
  throw new Error("verification is still running or was interrupted; wait for its result or close the task as interrupted");
379
441
  }
380
- const finished = TaskSchema.parse({ ...task, state, finished_at: new Date().toISOString() });
442
+ const finished = TaskSchema.parse({ ...task, state, finished_at: new Date().toISOString(), closed_by: by });
381
443
  db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(finished), taskId);
382
444
  return finished;
383
445
  }));
@@ -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)
@@ -203,12 +203,12 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = 1
203
203
  throw new Error(`verification timeout must be between 1 and ${MAX_CHECK_TIMEOUT_MS} ms`);
204
204
  options.signal?.throwIfAborted();
205
205
  const task = readTaskReport(root, taskId).task;
206
- if (task.state !== "open")
206
+ if (task.state !== "open" && task.closed_by !== "host")
207
207
  throw new Error("cannot verify a closed task");
208
208
  const before = reportSourceSnapshot(root);
209
209
  // Validate sensitive arguments before executing or writing anything.
210
210
  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);
211
+ const checkId = beginReportCheck(root, taskId, label, timeoutMs);
212
212
  const result = await new Promise((resolveResult) => {
213
213
  // Windows launchers (npx.cmd, npm.cmd, other .cmd/.bat shims) cannot be spawned
214
214
  // without a shell; resolve them first so a check actually runs instead of
@@ -21,8 +21,17 @@ 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 presentation notice never denies Stop or injects another model turn. Stop
25
- * can precede another hook's continuation, so it does not close an open task.
24
+ /** Stop ends the turn, so the prompt's task closes here as a HOST close: the
25
+ * ledger says the task completed even when the agent never called finish, and
26
+ * a task with observations becomes a graph record without anyone's cooperation.
27
+ * The close is provisional because Stop can precede another hook's
28
+ * continuation: the next observation reopens the task and the following Stop
29
+ * closes it again (the record is refreshed from the report). An explicit agent
30
+ * finish with any outcome overrides a host close. Pending verification keeps
31
+ * the task open. Returns the task id when the task is closed after this call,
32
+ * so the caller can persist its record; null when nothing is closed. */
33
+ export declare function closeHookTask(root: string, provider: HookProvider, event: HunchHookInput): string | null;
34
+ /** A presentation notice never denies Stop or injects another model turn.
26
35
  * A prompt with no observation at all prints nothing: the empty task row stays
27
36
  * in the ledger (hunch task list, the VS Code Contribution view) so "never
28
37
  * 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 { continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, 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. */
@@ -98,9 +98,25 @@ export function startHookReport(root, provider, event) {
98
98
  return null;
99
99
  const cwdLiteral = JSON.stringify(cwd);
100
100
  const title = (promptTitlesEnabled(root) ? promptTaskTitle(event.prompt) : null) ?? NATIVE_TASK_TITLE;
101
+ // Continuity: a prompt that follows another of the same session within the
102
+ // window continues its task ("status", "next", "go" are the same work), and
103
+ // the episode's graph record is written under the first task's id. The key
104
+ // is a hash; the host session identifier itself is still never retained.
105
+ let links = {};
106
+ if (event.session_id) {
107
+ const sessionKey = reportHash([cwd, provider, event.session_id, event.agent_id ?? null]);
108
+ links = { session_key: sessionKey };
109
+ try {
110
+ const previous = latestSessionTask(root, sessionKey);
111
+ const continued = previous && previous.task_id !== id ? continuationLinks(previous) : null;
112
+ if (continued)
113
+ links = { ...links, ...continued };
114
+ }
115
+ catch { /* no continuity; still a task */ }
116
+ }
101
117
  let task;
102
118
  try {
103
- task = startReportTask(root, title, id);
119
+ task = startReportTask(root, title, id, links);
104
120
  }
105
121
  catch (error) {
106
122
  // The same prompt identity may already be open: a release that called every
@@ -113,8 +129,39 @@ export function startHookReport(root, provider, event) {
113
129
  }
114
130
  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
131
  }
116
- /** A presentation notice never denies Stop or injects another model turn. Stop
117
- * can precede another hook's continuation, so it does not close an open task.
132
+ /** Stop ends the turn, so the prompt's task closes here as a HOST close: the
133
+ * ledger says the task completed even when the agent never called finish, and
134
+ * a task with observations becomes a graph record without anyone's cooperation.
135
+ * The close is provisional because Stop can precede another hook's
136
+ * continuation: the next observation reopens the task and the following Stop
137
+ * closes it again (the record is refreshed from the report). An explicit agent
138
+ * finish with any outcome overrides a host close. Pending verification keeps
139
+ * the task open. Returns the task id when the task is closed after this call,
140
+ * so the caller can persist its record; null when nothing is closed. */
141
+ export function closeHookTask(root, provider, event) {
142
+ let id;
143
+ try {
144
+ id = identity(root, provider, event);
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ if (!id || id === "legacy")
150
+ return null;
151
+ try {
152
+ const task = readTaskReport(root, id).task;
153
+ if (task.state === "interrupted")
154
+ return null;
155
+ if (task.state === "open")
156
+ finishReportTask(root, id, "completed", { by: "host" });
157
+ return id;
158
+ }
159
+ catch {
160
+ // No task for this prompt, or verification still running: leave it as it is.
161
+ return null;
162
+ }
163
+ }
164
+ /** A presentation notice never denies Stop or injects another model turn.
118
165
  * A prompt with no observation at all prints nothing: the empty task row stays
119
166
  * in the ledger (hunch task list, the VS Code Contribution view) so "never
120
167
  * touched Hunch" remains countable without a five-line notice per prompt. */
@@ -0,0 +1,5 @@
1
+ export declare function gitTouchedFiles(root: string, startedAt: string, finishedAt: string | null, options?: {
2
+ limit?: number;
3
+ timeoutMs?: number;
4
+ now?: number;
5
+ }): string[];
@@ -0,0 +1,77 @@
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
+ * Hunch's own memory and cache paths are excluded, so a capture commit made
13
+ * during the task does not count as work on a file. Deleted paths are skipped:
14
+ * nothing dates the deletion. Commit dates get one second of slack (git keeps
15
+ * seconds); working-tree mtimes get none before the start. */
16
+ import { execFileSync } from "node:child_process";
17
+ import { statSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ const EXCLUDED_SEGMENTS = new Set([".hunch", ".hunch-cache", ".git"]);
20
+ function gitDate(ms) {
21
+ // Second resolution, a format every git accepts.
22
+ return `${new Date(ms).toISOString().slice(0, 19).replace("T", " ")} +0000`;
23
+ }
24
+ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
25
+ const limit = Math.max(1, options.limit ?? 64);
26
+ const timeout = options.timeoutMs ?? 3_000;
27
+ const since = Date.parse(startedAt);
28
+ if (!Number.isFinite(since))
29
+ return [];
30
+ const until = finishedAt ? Date.parse(finishedAt) : (options.now ?? Date.now());
31
+ if (!Number.isFinite(until) || until < since)
32
+ return [];
33
+ // One second of slack on each side: git dates and some filesystems are second-granular.
34
+ const from = since - 1_000, to = until + 1_000;
35
+ const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
36
+ for (const key of Object.keys(env))
37
+ if (key.startsWith("GIT_") && key !== "GIT_OPTIONAL_LOCKS")
38
+ delete env[key];
39
+ const run = (args) => execFileSync("git", ["-C", root, "-c", "core.quotePath=false", ...args], { env, encoding: "utf8", timeout, maxBuffer: 4_000_000, stdio: ["ignore", "pipe", "ignore"] });
40
+ const out = new Set();
41
+ const keep = (raw) => {
42
+ const path = raw.trim().replace(/\\/g, "/").replace(/^\.\//, "");
43
+ if (!path || path.split("/").some((segment) => EXCLUDED_SEGMENTS.has(segment)))
44
+ return;
45
+ out.add(path);
46
+ };
47
+ try {
48
+ const email = run(["config", "--get", "user.email"]).trim();
49
+ if (email) {
50
+ const log = run(["log", "--no-merges", "-n", "50", `--since=${gitDate(from)}`, `--until=${gitDate(to)}`, `--author=${email}`, "--format=", "--name-only"]);
51
+ for (const line of log.split("\n"))
52
+ keep(line);
53
+ }
54
+ }
55
+ catch { /* no commits, no git user, or no git: the working tree may still say something */ }
56
+ try {
57
+ const entries = run(["status", "--porcelain=v1", "-z", "--untracked-files=all"]).split("\0").filter(Boolean);
58
+ for (let i = 0; i < entries.length; i++) {
59
+ const entry = entries[i];
60
+ const code = entry.slice(0, 2), path = entry.slice(3);
61
+ // A rename or copy is followed by its original path as a separate entry.
62
+ if (code[0] === "R" || code[0] === "C")
63
+ i++;
64
+ if (code.includes("D") || !path)
65
+ continue;
66
+ try {
67
+ const mtime = statSync(join(root, path)).mtimeMs;
68
+ if (mtime >= since && mtime <= to)
69
+ keep(path);
70
+ }
71
+ catch { /* vanished between status and stat */ }
72
+ }
73
+ }
74
+ catch { /* not a git worktree or status failed: nothing to add */ }
75
+ return [...out].sort().slice(0, limit);
76
+ }
77
+ //# sourceMappingURL=taskTouched.js.map
@@ -1113,7 +1113,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1113
1113
  const stateGrounding = asOf ? [] : stateSupplements(store.stateSlice(target), target);
1114
1114
  // Recent finished tasks that touched the target: what earlier agent work did
1115
1115
  // here, from graph memory. Advisory history sharing the brief's budget.
1116
- const recentTasks = asOf ? [] : taskSelectionSupplements(store.selectTasksFor(target, buildTaskRankingQuery(root, task_id ?? null, target)), target);
1116
+ const recentTasks = asOf ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, task_id ?? null, target)), target);
1117
1117
  const options = {
1118
1118
  root,
1119
1119
  symbols: store.recs("symbols"),
@@ -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;
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { fileURLToPath } from "node:url";
2
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
3
  import { TaskIdSchema, ReportClaimSchema, LessonReferenceSchema, finishReportTask, listReportTasks, readTaskReport, readLessonHistory, recordReportClaim, reportPresentationEnabled, startReportTask } from "../core/taskReport.js";
4
4
  import { persistTaskRecord } from "../core/taskRecord.js";
5
5
  import { reportSourceSnapshot, runReportConformance } from "../core/taskReportEvidence.js";
@@ -53,7 +53,11 @@ export function boundedTaskReportForHost(report) {
53
53
  function verificationLauncher() {
54
54
  const dev = import.meta.url.endsWith(".ts");
55
55
  const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, import.meta.url));
56
- const argv = [process.execPath, ...(dev ? ["--import", fileURLToPath(import.meta.resolve("tsx"))] : []), entry];
56
+ // `--import` takes a URL. Converting the resolved loader to a path made Node on
57
+ // Windows reject it ("Received protocol 'c:'"), so every verification launched
58
+ // from a source checkout there failed before running and cards showed no check.
59
+ const loader = import.meta.resolve("tsx");
60
+ const argv = [process.execPath, ...(dev ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
57
61
  const quote = (s) => process.platform === "win32" ? `'${s.replace(/'/g, "''")}'` : `'${s.replace(/'/g, "'\\''")}'`;
58
62
  return { argv, shell: `${process.platform === "win32" ? "& " : ""}${argv.map(quote).join(" ")}` };
59
63
  }
@@ -428,7 +428,11 @@ export declare class HunchStore {
428
428
  /** Gate → score → slots for one target and the current task's query (dec_66925aa0ee). */
429
429
  selectTasksFor(target: string, query: RankingQuery, options?: SlotOptions & {
430
430
  weights?: Readonly<RankingWeights>;
431
+ mode?: "ranked" | "latest";
431
432
  }): TaskSelection;
433
+ /** What delivery uses: the mode comes from the automatic evaluation (or a
434
+ * local pin), never from a flag the caller has to remember. */
435
+ selectTasksAuto(target: string, query: RankingQuery): TaskSelection;
432
436
  /** The causal chain behind a constraint — the WHY a diff-only reviewer can't see.
433
437
  * Deterministic graph join: constraint → source_decision (the decision that
434
438
  * motivated the guard) → the bug whose root cause spawned it (via
@@ -22,7 +22,8 @@ import { gitCommonDir, gitWorktreeRoot, isolatedHeadSha, sameGitPublication, sco
22
22
  import { pathMatchesGlob, pathsRelated } from "../core/glob.js";
23
23
  import { cochangeFor } from "../core/cochange.js";
24
24
  import { withServedDatabase } from "../core/served.js";
25
- import { normalizePath, rankTaskRecords, recordIdsOf, selectTaskSlots } from "../core/taskRanking.js";
25
+ import { normalizePath, rankTaskRecords, recordIdsOf, selectLatestTasks, selectTaskSlots } from "../core/taskRanking.js";
26
+ import { resolveTaskRankingMode } from "../core/taskRankingMode.js";
26
27
  import { currentForTopic, isInForce } from "../core/topics.js";
27
28
  import { edgeId } from "../core/ids.js";
28
29
  import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
@@ -1560,9 +1561,18 @@ export class HunchStore {
1560
1561
  /** Gate → score → slots for one target and the current task's query (dec_66925aa0ee). */
1561
1562
  selectTasksFor(target, query, options = {}) {
1562
1563
  const ctx = this.taskRankingContext(target, query);
1563
- const ranked = rankTaskRecords(this.taskCandidates(target, query, ctx), query, ctx, options.weights);
1564
+ const candidates = this.taskCandidates(target, query, ctx);
1565
+ if (options.mode === "latest")
1566
+ return selectLatestTasks(candidates, query, ctx);
1567
+ const ranked = rankTaskRecords(candidates, query, ctx, options.weights);
1564
1568
  return selectTaskSlots(ranked, options);
1565
1569
  }
1570
+ /** What delivery uses: the mode comes from the automatic evaluation (or a
1571
+ * local pin), never from a flag the caller has to remember. */
1572
+ selectTasksAuto(target, query) {
1573
+ const resolved = resolveTaskRankingMode(this.paths.root, this);
1574
+ return this.selectTasksFor(target, query, { mode: resolved.mode });
1575
+ }
1566
1576
  /** The causal chain behind a constraint — the WHY a diff-only reviewer can't see.
1567
1577
  * Deterministic graph join: constraint → source_decision (the decision that
1568
1578
  * motivated the guard) → the bug whose root cause spawned it (via
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.37.0",
3
+ "version": "1.38.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.37.0",
10
+ "version": "1.38.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.37.0",
16
+ "version": "1.38.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {