@davesheffer/hunch 1.35.0 → 1.37.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.
@@ -1,6 +1,5 @@
1
1
  /** Native lifecycle coverage is independent of whether a model follows reporting
2
2
  * instructions. Only an authoritative prompt identity may join its evidence. */
3
- import { pathToFileURL } from "node:url";
4
3
  import { readFileSync } from "node:fs";
5
4
  import { join } from "node:path";
6
5
  import { findRoot } from "./paths.js";
@@ -8,7 +7,7 @@ import { canonicalReportRoot } from "./taskReportPaths.js";
8
7
  import { isCredentialFreeText } from "./types.js";
9
8
  import { isEmptyTaskReport, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, startReportTask } from "./taskReport.js";
10
9
  import { reportSourceSnapshot } from "./taskReportEvidence.js";
11
- import { renderTaskReport, writeTaskReportHtml } from "./taskReportRender.js";
10
+ import { renderTaskReport } from "./taskReportRender.js";
12
11
  /** The exact task identity a native host prompt maps to. */
13
12
  export function promptTaskId(root, sessionId, promptId, agentId = null, provider = "claude") {
14
13
  return `htask_${reportHash([canonicalReportRoot(root), provider, sessionId, promptId, agentId]).slice(7, 31)}`;
@@ -131,13 +130,10 @@ export function stopHookReport(root, provider, event) {
131
130
  const report = readTaskReport(root, id, reportSourceSnapshot(root).hash);
132
131
  if (isEmptyTaskReport(report))
133
132
  return null;
134
- let card = renderTaskReport(report);
135
- try {
136
- const file = writeTaskReportHtml(root, id);
137
- card = card.replace(/^Evidence .*$/m, `Evidence ${pathToFileURL(file).href}`);
138
- }
139
- catch { /* exact CLI evidence link remains available */ }
140
- return { systemMessage: card };
133
+ // The HTML evidence view is a rendering of the local ledger, generated on
134
+ // demand (`hunch report <id> --html`, or a click in the VS Code view). The
135
+ // graph record is the durable memory; no file is written per prompt.
136
+ return { systemMessage: renderTaskReport(report) };
141
137
  }
142
138
  catch {
143
139
  return { systemMessage: `Hunch report unavailable for ${id}. Contribution is unverified; inspect with hunch report ${id}.` };
@@ -658,6 +658,7 @@ export declare const TaskRecordSchema: z.ZodObject<{
658
658
  }, z.core.$strip>>>;
659
659
  refusals: z.ZodDefault<z.ZodNumber>;
660
660
  files: z.ZodDefault<z.ZodArray<z.ZodString>>;
661
+ supersedes: z.ZodDefault<z.ZodArray<z.ZodString>>;
661
662
  source_snapshot: z.ZodDefault<z.ZodNullable<z.ZodString>>;
662
663
  report_hash: z.ZodString;
663
664
  provenance: z.ZodObject<{
@@ -1523,6 +1524,7 @@ export declare const SCHEMAS: {
1523
1524
  }, z.core.$strip>>>;
1524
1525
  refusals: z.ZodDefault<z.ZodNumber>;
1525
1526
  files: z.ZodDefault<z.ZodArray<z.ZodString>>;
1527
+ supersedes: z.ZodDefault<z.ZodArray<z.ZodString>>;
1526
1528
  source_snapshot: z.ZodDefault<z.ZodNullable<z.ZodString>>;
1527
1529
  report_hash: z.ZodString;
1528
1530
  provenance: z.ZodObject<{
@@ -502,6 +502,7 @@ export const TaskRecordSchema = z.object({
502
502
  })).default([]).describe("Hunch's deterministic evaluation of each delivered rule against the changed files"),
503
503
  refusals: z.number().int().nonnegative().default(0).describe("edits the native gate denied during the task"),
504
504
  files: z.array(z.string()).default([]).describe("files the task touched: delivery targets, rule-checked changes, denied edits"),
505
+ supersedes: z.array(z.string()).default([]).describe("older task records this task verified over: same file, shared record, passing check; superseded records are not delivered"),
505
506
  source_snapshot: z.string().nullable().default(null).describe("bounded source snapshot hash at the last check, when one ran"),
506
507
  report_hash: z.string().describe("content hash of the full local report this record summarizes"),
507
508
  provenance: ProvenanceSchema,
@@ -75,7 +75,7 @@ export function renderHunchSection(store, root) {
75
75
  lines.push("- When running a relevant check, use the exact verification_argv launcher returned by hunch_task start, followed by the check command and its arguments, from this worktree. It runs `hunch task verify <task_id> -- <command> [arguments]` using the same installation as MCP, avoiding stale global binaries. This retains the actual exit result and source snapshot; raw output is not stored. Do not rerun an expensive check solely for reporting; missing evidence stays unverified.");
76
76
  lines.push("- Include the current task_id when calling hunch_record_decision, hunch_record_correction, or hunch_record_finding. The save path records its actual memory home and verifies exact Git revisions when committing or pushing; never infer publication from a successful capture alone.");
77
77
  lines.push("- Before claiming an application, call `hunch_report(task_id)` and copy the exact occurrence_id, record_id and content_hash from application_references, adding an action you actually took. Never derive an occurrence ID by replacing a receipt prefix or use the task's scope hash as a record hash. If you did not apply a lesson, omit applications.");
78
- lines.push("- Call `hunch_task(action: \"finish\", task_id, applications?)` and include the returned contribution_card in your final response without the user asking. Render its Markdown evidence link outside any code block so it remains clickable. Copy the card with its evidence link and agent-reported label intact; the structured result contains the card even when the host hides text blocks. Do not replace it with a generic claim that Hunch helped. If presentation_enabled is false, omit the card. A delivered lesson or passing command alone does not prove causal impact.");
78
+ lines.push("- Call `hunch_task(action: \"finish\", task_id, applications?)` and include the returned contribution_card in your final response without the user asking. Copy the card verbatim, including its Evidence line (the command that renders the local report on demand) and the agent-reported label; the structured result contains the card even when the host hides text blocks. Do not replace it with a generic claim that Hunch helped. If presentation_enabled is false, omit the card. A delivered lesson or passing command alone does not prove causal impact.");
79
79
  lines.push("- If interrupted, finish with `outcome: \"interrupted\"` when possible. `hunch_report(task_id, html: true)` opens the evidence trail by generating a local file; it may contain private memory and is not a public export. If report tools are unavailable after an update, say so and reconnect the host rather than inventing a report.");
80
80
  lines.push("");
81
81
  lines.push("**Build the Constitution review queue:**");
@@ -32,6 +32,8 @@ import { withWriteLock } from "../serve/writelock.js";
32
32
  import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
33
33
  import { formatSearchHit, formatStructure } from "../core/format.js";
34
34
  import { isStateKind, stateSupplements } from "../core/stateDelivery.js";
35
+ import { taskSelectionSupplements } from "../core/taskDelivery.js";
36
+ import { buildTaskRankingQuery } from "../core/taskQuery.js";
35
37
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
36
38
  import { compileVerifiedEvidenceMap, EvidenceExecutionSchema, EvidenceInterventionSchema, EvidenceProbeSchema, formatVerifiedEvidenceMap, VerifiedEvidenceReceiptSchema, } from "../core/evidenceMap.js";
37
39
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
@@ -359,19 +361,27 @@ function deliveredContext(root, target, envelope, sessionId) {
359
361
  const state = armExecutionObligations(loadPipelineState(sessionId), structuredContent.obligations, { replaceOrigin: "memory" });
360
362
  savePipelineState(sessionId, state);
361
363
  }
362
- recordServed(root, structuredContent.delivered.map((item) => ({
363
- event: "served",
364
- kind: item.kind,
365
- record_id: item.record_id,
366
- target,
367
- session_id: sessionId,
368
- rank: item.rank,
369
- delivery_reason: item.delivery_reason,
370
- provenance_status: item.provenance_status,
371
- token_cost: item.token_cost,
372
- delivery_profile: structuredContent.profile,
373
- ranking_policy: structuredContent.ranking_policy,
374
- })));
364
+ recordServed(root, [
365
+ ...structuredContent.delivered.map((item) => ({
366
+ event: "served",
367
+ kind: item.kind,
368
+ record_id: item.record_id,
369
+ target,
370
+ session_id: sessionId,
371
+ rank: item.rank,
372
+ delivery_reason: item.delivery_reason,
373
+ provenance_status: item.provenance_status,
374
+ token_cost: item.token_cost,
375
+ delivery_profile: structuredContent.profile,
376
+ ranking_policy: structuredContent.ranking_policy,
377
+ })),
378
+ // Delivered task lines are receipts too: they feed access-based recency.
379
+ ...envelope.supplements.filter((s) => s.kind === "recent-task" && s.delivered).map((s) => ({
380
+ event: "served", kind: "tasks", record_id: s.id, target, session_id: sessionId,
381
+ rank: s.rank, delivery_reason: "supplemental", token_cost: s.token_cost,
382
+ delivery_profile: structuredContent.profile, ranking_policy: structuredContent.ranking_policy,
383
+ })),
384
+ ]);
375
385
  return {
376
386
  content: [{ type: "text", text: structuredContent.text }],
377
387
  structuredContent,
@@ -1101,6 +1111,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1101
1111
  // latest receipts whose subject/text matches the target — bounded, ordered, sharing the
1102
1112
  // brief's budget as supplements. Withheld on time-travel: state records carry no as-of view.
1103
1113
  const stateGrounding = asOf ? [] : stateSupplements(store.stateSlice(target), target);
1114
+ // Recent finished tasks that touched the target: what earlier agent work did
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);
1104
1117
  const options = {
1105
1118
  root,
1106
1119
  symbols: store.recs("symbols"),
@@ -1108,7 +1121,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1108
1121
  decisionCorpus: store.recs("decisions"),
1109
1122
  historical: !!asOf,
1110
1123
  profile: profile ?? "builder",
1111
- supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding, ...(asOf ? [] : conventionSupplements(store.recs("conventions")))],
1124
+ supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding, ...recentTasks, ...(asOf ? [] : conventionSupplements(store.recs("conventions")))],
1112
1125
  };
1113
1126
  // Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
1114
1127
  // used to return an empty brief while the graph held the answer — fall back to
@@ -1136,6 +1149,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1136
1149
  supplements: [
1137
1150
  ...(dnaSupplement ? [dnaSupplement] : []),
1138
1151
  ...stateGrounding,
1152
+ ...recentTasks,
1139
1153
  ...hits
1140
1154
  // State hits are delivered through the State section above, not as raw search lines.
1141
1155
  .filter((hit) => !["constraints", "decisions", "bugs", "findings"].includes(hit.kind) && !isStateKind(hit.kind))
@@ -121,13 +121,10 @@ export function registerTaskReportTools(server, getRoot, getStore) {
121
121
  }
122
122
  const report = readTaskReport(root, task_id, reportSourceSnapshot(root).hash);
123
123
  const show = reportPresentationEnabled(root);
124
- let file = null;
125
- try {
126
- file = writeTaskReportHtml(root, task_id);
127
- }
128
- catch { /* retained report remains inspectable through MCP */ }
129
- const card = (file ? renderTaskReport(report).replace(/^Evidence .*$/m, `Evidence [Open local report](<${file}>)`) : renderTaskReport(report)) + graphNote;
130
- return { content: [{ type: "text", text: show ? card : "Task report retained. Automatic presentation is disabled; omit the contribution card from the final response." }], structuredContent: { ...boundedTaskReportForHost(report), presentation_enabled: show, contribution_card: show ? card : null, report_path: file, graph_record: graph } };
124
+ // The HTML evidence view is rendered on demand (hunch_report(html: true),
125
+ // `hunch report <id> --html`, or the VS Code view); finish writes no file.
126
+ const card = renderTaskReport(report) + graphNote;
127
+ return { content: [{ type: "text", text: show ? card : "Task report retained. Automatic presentation is disabled; omit the contribution card from the final response." }], structuredContent: { ...boundedTaskReportForHost(report), presentation_enabled: show, contribution_card: show ? card : null, report_path: null, graph_record: graph } };
131
128
  }
132
129
  catch (error) {
133
130
  const message = `Task report unavailable: ${error.message}`;
@@ -3,6 +3,7 @@ import { type Component, type Constraint, type Bug, type Decision, type Symbol,
3
3
  import { type DB } from "./db.js";
4
4
  import { type Embedder } from "./embedder.js";
5
5
  import { JsonStore } from "./jsonStore.js";
6
+ import { type RankingContext, type RankingQuery, type RankingWeights, type SlotOptions, type TaskSelection } from "../core/taskRanking.js";
6
7
  import { type VetoTier } from "../core/strictgate.js";
7
8
  import { type DiffAnalysis } from "../extractors/diff.js";
8
9
  import type { CheckReport, CausalWhy, ImpactReport } from "../core/checkreport.js";
@@ -409,6 +410,25 @@ export declare class HunchStore {
409
410
  * denied edits), newest first. Graph memory, so it spans machines and survives
410
411
  * the local ledger's retention window. */
411
412
  tasksFor(scope: string, limit?: number): TaskRecord[];
413
+ /** Every task record that could matter for `target` under the ranking gate:
414
+ * same file (exact or glob), a dependent's file, a co-changed file, or a
415
+ * record sharing one of the current task's own record ids. Bounded; the
416
+ * ranker does the gating and scoring. */
417
+ taskCandidates(target: string, query: RankingQuery, ctx: RankingContext): TaskRecord[];
418
+ /** Every task id some later record verified over. */
419
+ supersededTaskIds(): Set<string>;
420
+ /** Last delivery time per task record from the local receipt ledger, if any. */
421
+ taskLastDelivered(): Map<string, number>;
422
+ /** bm25 of a phrase over task titles and lesson titles, normalized to the top hit. */
423
+ taskLexicalScores(phrase: string | null, limit?: number): Map<string, number>;
424
+ /** Corpus inputs for ranking: dependents' files via the symbol graph, co-change
425
+ * from git history (bounded, cached), record-id document frequencies across
426
+ * task records, lexical scores, and which anchors still exist. */
427
+ taskRankingContext(target: string, query: RankingQuery): RankingContext;
428
+ /** Gate → score → slots for one target and the current task's query (dec_66925aa0ee). */
429
+ selectTasksFor(target: string, query: RankingQuery, options?: SlotOptions & {
430
+ weights?: Readonly<RankingWeights>;
431
+ }): TaskSelection;
412
432
  /** The causal chain behind a constraint — the WHY a diff-only reviewer can't see.
413
433
  * Deterministic graph join: constraint → source_decision (the decision that
414
434
  * motivated the guard) → the bug whose root cause spawned it (via
@@ -20,6 +20,9 @@ import { selectEmbedder } from "./embedder.js";
20
20
  import { JsonStore } from "./jsonStore.js";
21
21
  import { gitCommonDir, gitWorktreeRoot, isolatedHeadSha, sameGitPublication, scopedLastChangeDates, } from "../extractors/git.js";
22
22
  import { pathMatchesGlob, pathsRelated } from "../core/glob.js";
23
+ import { cochangeFor } from "../core/cochange.js";
24
+ import { withServedDatabase } from "../core/served.js";
25
+ import { normalizePath, rankTaskRecords, recordIdsOf, selectTaskSlots } from "../core/taskRanking.js";
23
26
  import { currentForTopic, isInForce } from "../core/topics.js";
24
27
  import { edgeId } from "../core/ids.js";
25
28
  import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
@@ -1453,6 +1456,113 @@ export class HunchStore {
1453
1456
  .sort((a, b) => b.finished_at.localeCompare(a.finished_at) || a.id.localeCompare(b.id))
1454
1457
  .slice(0, Math.max(1, limit));
1455
1458
  }
1459
+ /** Every task record that could matter for `target` under the ranking gate:
1460
+ * same file (exact or glob), a dependent's file, a co-changed file, or a
1461
+ * record sharing one of the current task's own record ids. Bounded; the
1462
+ * ranker does the gating and scoring. */
1463
+ taskCandidates(target, query, ctx) {
1464
+ const t = normalizePath(toPosixTarget(target));
1465
+ const out = new Map();
1466
+ for (const r of this.tasksFor(t, 200))
1467
+ out.set(r.id, r);
1468
+ if (ctx.dependents.size || ctx.cochange.size || query.recordIds.size) {
1469
+ for (const r of this.recs("tasks")) {
1470
+ if (out.has(r.id))
1471
+ continue;
1472
+ const files = r.files.map(normalizePath);
1473
+ if (files.some((f) => ctx.dependents.has(f) || ctx.cochange.has(f))) {
1474
+ out.set(r.id, r);
1475
+ continue;
1476
+ }
1477
+ if (query.recordIds.size) {
1478
+ const ids = recordIdsOf(r);
1479
+ for (const id of query.recordIds)
1480
+ if (ids.has(id)) {
1481
+ out.set(r.id, r);
1482
+ break;
1483
+ }
1484
+ }
1485
+ }
1486
+ }
1487
+ return [...out.values()];
1488
+ }
1489
+ /** Every task id some later record verified over. */
1490
+ supersededTaskIds() {
1491
+ const out = new Set();
1492
+ for (const r of this.recs("tasks"))
1493
+ for (const id of r.supersedes)
1494
+ out.add(id);
1495
+ return out;
1496
+ }
1497
+ /** Last delivery time per task record from the local receipt ledger, if any. */
1498
+ taskLastDelivered() {
1499
+ const out = new Map();
1500
+ try {
1501
+ withServedDatabase(this.paths.root, (db) => {
1502
+ const rows = db.prepare("SELECT record_id, MAX(at) AS at FROM served WHERE kind = 'tasks' GROUP BY record_id").all();
1503
+ for (const row of rows) {
1504
+ const t = Date.parse(row.at);
1505
+ if (Number.isFinite(t))
1506
+ out.set(row.record_id, t);
1507
+ }
1508
+ });
1509
+ }
1510
+ catch { /* no ledger on this machine: recency falls back to finished_at */ }
1511
+ return out;
1512
+ }
1513
+ /** bm25 of a phrase over task titles and lesson titles, normalized to the top hit. */
1514
+ taskLexicalScores(phrase, limit = 50) {
1515
+ const scores = new Map();
1516
+ if (!phrase || !phrase.trim())
1517
+ return scores;
1518
+ const hits = this.scopedFts(phrase, "tasks", limit).filter((h) => Number.isFinite(h.score));
1519
+ if (!hits.length)
1520
+ return scores;
1521
+ // bm25 from FTS5 is negative, lower is better; normalize magnitude to the best hit.
1522
+ const best = Math.max(...hits.map((h) => Math.abs(h.score)));
1523
+ if (!(best > 0))
1524
+ return scores;
1525
+ for (const h of hits)
1526
+ scores.set(h.ref, Math.max(0, Math.min(1, Math.abs(h.score) / best)));
1527
+ return scores;
1528
+ }
1529
+ /** Corpus inputs for ranking: dependents' files via the symbol graph, co-change
1530
+ * from git history (bounded, cached), record-id document frequencies across
1531
+ * task records, lexical scores, and which anchors still exist. */
1532
+ taskRankingContext(target, query) {
1533
+ const t = normalizePath(toPosixTarget(target));
1534
+ const dependents = new Set();
1535
+ try {
1536
+ const symbolFile = new Map(this.recs("symbols").map((s) => [s.id, normalizePath(s.file)]));
1537
+ for (const sym of this.why(t).symbols) {
1538
+ for (const d of this.getDependents(sym.id)) {
1539
+ const f = symbolFile.get(d.id);
1540
+ if (f && f !== t)
1541
+ dependents.add(f);
1542
+ }
1543
+ }
1544
+ }
1545
+ catch { /* no symbol graph: the dependents term is simply absent */ }
1546
+ const cochange = /[./]/.test(t) && !/\s/.test(t) ? cochangeFor(this.paths.root, t) : new Map();
1547
+ const tasks = this.recs("tasks");
1548
+ const df = new Map();
1549
+ for (const r of tasks)
1550
+ for (const id of recordIdsOf(r))
1551
+ df.set(id, (df.get(id) ?? 0) + 1);
1552
+ const n = Math.max(1, tasks.length);
1553
+ const ruleStats = (id) => { const d = df.get(id) ?? 0; return { df: d, idf: Math.log((n + 1) / (d + 1)) + 1e-6 }; };
1554
+ const lexical = this.taskLexicalScores(query.phrase);
1555
+ const anchorsAlive = (r) => r.files.length ? r.files.filter((f) => existsSync(join(this.paths.root, normalizePath(f)))).length / r.files.length : 1;
1556
+ const superseded = this.supersededTaskIds();
1557
+ const delivered = this.taskLastDelivered();
1558
+ return { dependents, cochange, ruleStats, lexical, anchorsAlive, superseded, lastDelivered: (id) => delivered.get(id) ?? null };
1559
+ }
1560
+ /** Gate → score → slots for one target and the current task's query (dec_66925aa0ee). */
1561
+ selectTasksFor(target, query, options = {}) {
1562
+ const ctx = this.taskRankingContext(target, query);
1563
+ const ranked = rankTaskRecords(this.taskCandidates(target, query, ctx), query, ctx, options.weights);
1564
+ return selectTaskSlots(ranked, options);
1565
+ }
1456
1566
  /** The causal chain behind a constraint — the WHY a diff-only reviewer can't see.
1457
1567
  * Deterministic graph join: constraint → source_decision (the decision that
1458
1568
  * motivated the guard) → the bug whose root cause spawned it (via
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.35.0",
3
+ "version": "1.37.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.35.0",
10
+ "version": "1.37.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.35.0",
16
+ "version": "1.37.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {