@davesheffer/hunch 1.35.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -66,6 +66,7 @@ import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refre
66
66
  import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
67
67
  import { formatSearchHit, formatStructure } from "../core/format.js";
68
68
  import { isStateKind, renderStateLine, stateSupplements } from "../core/stateDelivery.js";
69
+ import { taskSupplements } from "../core/taskDelivery.js";
69
70
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
70
71
  import { compileVerifiedEvidenceMap, formatVerifiedEvidenceMap } from "../core/evidenceMap.js";
71
72
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
@@ -4210,7 +4211,7 @@ program
4210
4211
  decisionCorpus: store.recs("decisions"),
4211
4212
  historical: !!asOf,
4212
4213
  profile: opts.profile,
4213
- supplements: stateGrounding,
4214
+ supplements: [...stateGrounding, ...(asOf ? [] : taskSupplements(store.tasksFor(target, 3), target))],
4214
4215
  });
4215
4216
  process.stdout.write(envelope.text);
4216
4217
  if (opts.task) {
@@ -4880,6 +4881,7 @@ program
4880
4881
  // from this file. No diff exists yet, so this is context — "don't re-add X" —
4881
4882
  // not a block; the commit-time `hunch check` does the actual gating.
4882
4883
  const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
4884
+ const recentTasks = taskSupplements(store.tasksFor(target, 3), target);
4883
4885
  const hasContent = ctx.constraints.length ||
4884
4886
  ctx.decisions.length ||
4885
4887
  ctx.bugs.length ||
@@ -4888,6 +4890,7 @@ program
4888
4890
  ctx.landscape?.resources.length ||
4889
4891
  ctx.landscape?.relationships.length ||
4890
4892
  retired.length ||
4893
+ recentTasks.length ||
4891
4894
  docGround;
4892
4895
  if (!hasContent)
4893
4896
  return; // no noise on files Hunch hasn't learned yet
@@ -4905,6 +4908,7 @@ program
4905
4908
  text: `⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ")}.`,
4906
4909
  }] : []),
4907
4910
  ...(docGround ? [{ id: "doc-grounding", kind: "doc-grounding", priority: 100, text: docGround }] : []),
4911
+ ...recentTasks,
4908
4912
  ],
4909
4913
  });
4910
4914
  const text = envelope.text.trim();
@@ -0,0 +1,14 @@
1
+ export interface ResolvedSpawn {
2
+ file: string;
3
+ args: string[];
4
+ /** Set when a batch launcher runs through cmd.exe and the line is pre-quoted. */
5
+ windowsVerbatimArguments?: boolean;
6
+ how: "direct" | "npm-cli" | "pathext" | "cmd-shim";
7
+ }
8
+ export interface SpawnResolveOptions {
9
+ platform?: NodeJS.Platform;
10
+ env?: NodeJS.ProcessEnv;
11
+ execPath?: string;
12
+ exists?: (path: string) => boolean;
13
+ }
14
+ export declare function resolveSpawnCommand(command: readonly string[], options?: SpawnResolveOptions): ResolvedSpawn;
@@ -0,0 +1,61 @@
1
+ /** Resolve a user-supplied argv into something `spawn` can run without a shell.
2
+ *
3
+ * On POSIX the argv is already right. On Windows, `spawn("npx", ...)` with
4
+ * `shell: false` fails: the launcher is `npx.cmd`, and Node refuses to run
5
+ * `.cmd`/`.bat` files directly. The verification runner used to swallow that
6
+ * as `exit_code: null`, so every contribution card on Windows said "no
7
+ * independent command result". This keeps `shell: false` for real
8
+ * executables and only routes batch launchers through `cmd.exe`, with the
9
+ * npm/npx launchers run as plain Node scripts (no shell at all). */
10
+ import { existsSync } from "node:fs";
11
+ import { posix, win32 } from "node:path";
12
+ /** cmd.exe quoting for one argument: wrap when it has whitespace or shell
13
+ * metacharacters; double embedded quotes. Good for test/build commands; a
14
+ * deliberately hostile argument still cannot escape because the whole line is
15
+ * passed as one `/s /c "..."` token. */
16
+ function quoteForCmd(arg) {
17
+ if (arg === "")
18
+ return '""';
19
+ if (!/[\s"&|<>^()%!]/.test(arg))
20
+ return arg;
21
+ return `"${arg.replace(/"/g, '""')}"`;
22
+ }
23
+ export function resolveSpawnCommand(command, options = {}) {
24
+ const platform = options.platform ?? process.platform;
25
+ const [cmd = "", ...args] = command;
26
+ if (platform !== "win32")
27
+ return { file: cmd, args, how: "direct" };
28
+ const env = options.env ?? process.env;
29
+ const exists = options.exists ?? existsSync;
30
+ const execPath = options.execPath ?? process.execPath;
31
+ // Resolve Windows paths with Windows semantics even when the resolution is
32
+ // exercised (tested) on another platform; the host's default `path` is POSIX there.
33
+ const { join, dirname } = platform === "win32" ? win32 : posix;
34
+ // npm / npx: run the CLI script with this same Node. No shim, no shell.
35
+ if (/^(npm|npx)$/i.test(cmd)) {
36
+ const script = join(dirname(execPath), "node_modules", "npm", "bin", `${cmd.toLowerCase()}-cli.js`);
37
+ if (exists(script))
38
+ return { file: execPath, args: [script, ...args], how: "npm-cli" };
39
+ }
40
+ // A path or an explicit executable extension: spawn as given.
41
+ if (/[\\/]/.test(cmd) || /\.(exe|com)$/i.test(cmd))
42
+ return { file: cmd, args, how: "direct" };
43
+ const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean);
44
+ const dirs = (env.PATH ?? env.Path ?? "").split(";").map((d) => d.trim()).filter(Boolean);
45
+ for (const dir of dirs) {
46
+ for (const ext of ["", ...pathExt]) {
47
+ const candidate = join(dir, cmd + ext);
48
+ if (!exists(candidate))
49
+ continue;
50
+ if (/\.(cmd|bat)$/i.test(candidate)) {
51
+ const line = [candidate, ...args].map(quoteForCmd).join(" ");
52
+ return { file: env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], windowsVerbatimArguments: true, how: "cmd-shim" };
53
+ }
54
+ if (ext === "" && !/\.(exe|com)$/i.test(candidate))
55
+ continue; // an extensionless file is not runnable on Windows
56
+ return { file: candidate, args, how: "pathext" };
57
+ }
58
+ }
59
+ return { file: cmd, args, how: "direct" };
60
+ }
61
+ //# sourceMappingURL=spawnCommand.js.map
@@ -0,0 +1,15 @@
1
+ /** Recent finished tasks as delivered context.
2
+ *
3
+ * Task records (`.hunch/tasks/`) say what earlier agent work did around a file:
4
+ * which lessons it received, what it applied, saved and checked, and whether a
5
+ * rule was violated. Delivering the newest few next to the invariants lets the
6
+ * next agent build on verified work instead of rediscovering it. Supplements
7
+ * share the brief's budget and are advisory: a task line is history, never a
8
+ * rule, and never an instruction to repeat or skip anything. */
9
+ import type { DeliverySupplement } from "./delivery.js";
10
+ import type { TaskRecord } from "./types.js";
11
+ export declare const TASK_SUPPLEMENT_LIMIT = 3;
12
+ /** One bounded line for a task: identity, when, what reached it, what it did. */
13
+ export declare function describeTaskRecord(t: TaskRecord): string;
14
+ /** Newest first, bounded. Empty input yields no supplement at all (no header noise). */
15
+ export declare function taskSupplements(tasks: readonly TaskRecord[], target: string, limit?: number): DeliverySupplement[];
@@ -0,0 +1,38 @@
1
+ export const TASK_SUPPLEMENT_LIMIT = 3;
2
+ function clip(text, max) {
3
+ return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
4
+ }
5
+ /** One bounded line for a task: identity, when, what reached it, what it did. */
6
+ export function describeTaskRecord(t) {
7
+ const when = t.finished_at.slice(0, 10);
8
+ const lessons = t.lessons.length
9
+ ? `${t.lessons.length} lesson(s): ${t.lessons.slice(0, 3).map((l) => l.record_id).join(", ")}${t.lessons.length > 3 ? "…" : ""}`
10
+ : "no memory delivered";
11
+ const applied = t.applied.length
12
+ ? `applied ${t.applied.length} (${t.applied.some((a) => a.supported_by) ? "rule-supported" : "agent-reported"})`
13
+ : null;
14
+ const saved = t.saved.length ? `saved ${t.saved.slice(0, 3).map((s) => s.record_id).join(", ")}${t.saved.length > 3 ? "…" : ""}` : null;
15
+ const last = t.checks.at(-1);
16
+ const check = last ? `check "${clip(last.label, 40)}" ${last.state}` : "no check recorded";
17
+ const violated = t.conformance.some((c) => c.outcome === "violated") ? "RULE VIOLATED" : null;
18
+ const denied = t.refusals ? `${t.refusals} edit(s) denied` : null;
19
+ const files = t.files.length ? `files ${t.files.slice(0, 4).join(", ")}${t.files.length > 4 ? "…" : ""}` : null;
20
+ return `${t.id} · ${when} · ${t.state} · "${clip(t.title, 80)}" — ${[lessons, applied, saved, check, violated, denied, files].filter(Boolean).join(" · ")}`;
21
+ }
22
+ /** Newest first, bounded. Empty input yields no supplement at all (no header noise). */
23
+ export function taskSupplements(tasks, target, limit = TASK_SUPPLEMENT_LIMIT) {
24
+ const recent = [...tasks]
25
+ .sort((a, b) => b.finished_at.localeCompare(a.finished_at) || a.id.localeCompare(b.id))
26
+ .slice(0, Math.max(1, limit));
27
+ if (!recent.length)
28
+ return [];
29
+ const older = tasks.length - recent.length;
30
+ return [
31
+ {
32
+ id: "recent-tasks", kind: "recent-tasks", priority: 415,
33
+ text: `RECENT TASKS on ${target} — earlier agent work here, from graph memory (advisory history, not rules): build on what was verified instead of redoing it blind.${older > 0 ? ` ${older} older task(s) not shown; hunch task list.` : ""}`,
34
+ },
35
+ ...recent.map((t, i) => ({ id: t.id, kind: "recent-task", priority: 414 - i, text: describeTaskRecord(t) })),
36
+ ];
37
+ }
38
+ //# sourceMappingURL=taskDelivery.js.map
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { execFile, execFileSync, spawn } from "node:child_process";
3
3
  import { lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
4
4
  import { canonicalReportRoot } from "./taskReportPaths.js";
5
+ import { resolveSpawnCommand } from "./spawnCommand.js";
5
6
  import { dirname, isAbsolute, relative, resolve } from "node:path";
6
7
  import { analyzeDiff } from "../extractors/diff.js";
7
8
  import { workingDiff, workingFiles } from "../extractors/git.js";
@@ -209,7 +210,11 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = 1
209
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" });
210
211
  const checkId = beginReportCheck(root, taskId, label);
211
212
  const result = await new Promise((resolveResult) => {
212
- const child = spawn(command[0], command.slice(1), { cwd: root, shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32" });
213
+ // Windows launchers (npx.cmd, npm.cmd, other .cmd/.bat shims) cannot be spawned
214
+ // without a shell; resolve them first so a check actually runs instead of
215
+ // silently recording exit_code null (fnd: every Windows card said "no result").
216
+ const resolved = resolveSpawnCommand(command);
217
+ const child = spawn(resolved.file, resolved.args, { cwd: root, shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32", windowsVerbatimArguments: resolved.windowsVerbatimArguments === true });
213
218
  const stdout = createHash("sha256"), stderr = createHash("sha256");
214
219
  let timedOut = false, cancelled = false, settled = false;
215
220
  let cleanupTimer;
@@ -258,7 +263,16 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = 1
258
263
  stderr.update(chunk);
259
264
  options.onStderr?.(chunk);
260
265
  } });
261
- child.once("error", () => settle(null));
266
+ child.once("error", (error) => {
267
+ // A launch failure is a result the user must see (ENOENT is the common
268
+ // one); it is hashed like any other stderr and streamed to the caller.
269
+ const message = Buffer.from(`hunch: could not start ${JSON.stringify(command[0])}: ${error.message}\n`);
270
+ if (!settled) {
271
+ stderr.update(message);
272
+ options.onStderr?.(message);
273
+ }
274
+ settle(null);
275
+ });
262
276
  child.once("close", code => settle(code));
263
277
  options.signal?.addEventListener("abort", cancel, { once: true });
264
278
  if (options.signal?.aborted)
@@ -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}.` };
@@ -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,7 @@ 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 { taskSupplements } from "../core/taskDelivery.js";
35
36
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
36
37
  import { compileVerifiedEvidenceMap, EvidenceExecutionSchema, EvidenceInterventionSchema, EvidenceProbeSchema, formatVerifiedEvidenceMap, VerifiedEvidenceReceiptSchema, } from "../core/evidenceMap.js";
37
38
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
@@ -1101,6 +1102,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1101
1102
  // latest receipts whose subject/text matches the target — bounded, ordered, sharing the
1102
1103
  // brief's budget as supplements. Withheld on time-travel: state records carry no as-of view.
1103
1104
  const stateGrounding = asOf ? [] : stateSupplements(store.stateSlice(target), target);
1105
+ // Recent finished tasks that touched the target: what earlier agent work did
1106
+ // here, from graph memory. Advisory history sharing the brief's budget.
1107
+ const recentTasks = asOf ? [] : taskSupplements(store.tasksFor(target, 3), target);
1104
1108
  const options = {
1105
1109
  root,
1106
1110
  symbols: store.recs("symbols"),
@@ -1108,7 +1112,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1108
1112
  decisionCorpus: store.recs("decisions"),
1109
1113
  historical: !!asOf,
1110
1114
  profile: profile ?? "builder",
1111
- supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding, ...(asOf ? [] : conventionSupplements(store.recs("conventions")))],
1115
+ supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding, ...recentTasks, ...(asOf ? [] : conventionSupplements(store.recs("conventions")))],
1112
1116
  };
1113
1117
  // Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
1114
1118
  // used to return an empty brief while the graph held the answer — fall back to
@@ -1136,6 +1140,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1136
1140
  supplements: [
1137
1141
  ...(dnaSupplement ? [dnaSupplement] : []),
1138
1142
  ...stateGrounding,
1143
+ ...recentTasks,
1139
1144
  ...hits
1140
1145
  // State hits are delivered through the State section above, not as raw search lines.
1141
1146
  .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}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.35.0",
3
+ "version": "1.36.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.36.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.36.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {