@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 +20 -3
- package/dist/cli/taskReport.js +5 -1
- package/dist/constitution/schema.d.ts +2 -2
- package/dist/constitution/scorecard.d.ts +2 -2
- package/dist/core/outcomeExperience.d.ts +1 -1
- package/dist/core/stateContract.d.ts +3 -3
- package/dist/core/taskDelivery.js +3 -1
- package/dist/core/taskRanking.d.ts +5 -0
- package/dist/core/taskRanking.js +13 -1
- package/dist/core/taskRankingMode.d.ts +36 -0
- package/dist/core/taskRankingMode.js +122 -0
- package/dist/core/taskRecord.d.ts +11 -2
- package/dist/core/taskRecord.js +97 -42
- package/dist/core/taskReport.d.ts +42 -3
- package/dist/core/taskReport.js +72 -10
- package/dist/core/taskReportEvidence.js +3 -3
- package/dist/core/taskReportHook.d.ts +11 -2
- package/dist/core/taskReportHook.js +51 -4
- package/dist/core/taskTouched.d.ts +5 -0
- package/dist/core/taskTouched.js +77 -0
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/taskReportTools.d.ts +8 -0
- package/dist/mcp/taskReportTools.js +6 -2
- package/dist/store/hunchStore.d.ts +4 -0
- package/dist/store/hunchStore.js +12 -2
- package/dist/taskReports.d.ts +8 -0
- package/package.json +1 -1
- package/server.json +2 -2
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.
|
|
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.
|
|
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();
|
package/dist/cli/taskReport.js
CHANGED
|
@@ -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 =
|
|
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;
|
package/dist/core/taskRanking.js
CHANGED
|
@@ -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
|
-
|
|
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,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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
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:
|
|
78
|
-
title
|
|
79
|
-
state: task.state,
|
|
80
|
-
started_at:
|
|
81
|
-
finished_at: task.finished_at,
|
|
82
|
-
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
|
|
85
|
-
saved
|
|
86
|
-
checks:
|
|
111
|
+
applied,
|
|
112
|
+
saved,
|
|
113
|
+
checks: checks.slice(-64),
|
|
87
114
|
conformance: [...latestRule.values()],
|
|
88
|
-
refusals
|
|
89
|
-
files: [...
|
|
90
|
-
source_snapshot:
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
148
|
-
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
|
+
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
|
-
|
|
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
|
|
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
|
-
|
|
227
|
-
|
|
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). */
|
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(),
|
|
@@ -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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
25
|
-
*
|
|
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
|
-
/**
|
|
117
|
-
*
|
|
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,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
|
package/dist/mcp/server.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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
|
|
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
|
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.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.
|
|
16
|
+
"version": "1.38.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|