@davesheffer/hunch 1.32.2 → 1.32.4
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/README.md +9 -4
- package/dist/cli/index.js +40 -7
- package/dist/cli/taskReport.js +80 -1
- package/dist/core/agenthook.d.ts +1 -1
- package/dist/core/agenthook.js +22 -3
- package/dist/core/config.d.ts +3 -0
- package/dist/core/config.js +4 -1
- package/dist/core/stateContract.d.ts +3 -0
- package/dist/core/stateContract.js +1 -0
- package/dist/core/taskReport.d.ts +49 -0
- package/dist/core/taskReport.js +99 -0
- package/dist/core/taskReportHook.d.ts +7 -1
- package/dist/core/taskReportHook.js +18 -5
- package/dist/extractors/git.js +25 -5
- package/dist/integrations/claudemd.js +1 -1
- package/dist/integrations/health.d.ts +19 -5
- package/dist/integrations/health.js +35 -10
- package/dist/integrations/providers.d.ts +7 -0
- package/dist/integrations/providers.js +27 -1
- package/dist/integrations/registry.d.ts +15 -0
- package/dist/integrations/registry.js +41 -0
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.js +345 -322
- package/dist/mcp/toolset.d.ts +30 -0
- package/dist/mcp/toolset.js +72 -0
- package/dist/store/stateBinding.js +16 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ hunch integrations check --harness codex --require context,edit-blocking
|
|
|
61
61
|
|
|
62
62
|
Capabilities are reported as **verified**, **advisory-only**, **unsupported** or **untested**. `--require` fails unless every named capability is verified. `mcp` is verified by a fresh-server probe; hook capabilities become verified only from lifecycle events actually delivered to Hunch's hook on the expected version within the last 30 days (machine-local evidence, the same trust level as the served ledger), so a repository whose agent has actually run shows it, and one that only has configuration does not.
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
Codex CLI 0.153+ gets a native lifecycle adapter (`.codex/hooks.json`: session orientation, prompt task IDs from `turn_id`, `apply_patch` pre-edit grounding and strict denial, Stop cards); project-layer hooks load only for a trusted project and must be trusted once in Codex with `/hooks`. The opt-in `--probe` verifies a fresh MCP process, not whether an existing host session or model actually followed the memory.
|
|
65
65
|
|
|
66
66
|
Use `hunch integrations check` in CI to prevent pin drift; add `--require` for capabilities your workflow cannot operate without.
|
|
67
67
|
|
|
@@ -183,7 +183,7 @@ Hunch may hold durable state **about** external work with credential-free proven
|
|
|
183
183
|
|
|
184
184
|
The first real-world pilot is **Sofia**, a working operations agent over CRM, Gmail and WhatsApp. Sofia's approved actions, follow-ups, customer/source relationships and cited summaries map naturally to action receipts, commitments, entities/relationships and dependency-bound state.
|
|
185
185
|
|
|
186
|
-
The pilot measures whether Sofia and a second, different agent stop re-deriving contradictory state when the deterministic state is delivered before they answer or act.
|
|
186
|
+
The pilot measures whether Sofia and a second, different agent stop re-deriving contradictory state when the deterministic state is delivered before they answer or act. First live number (2026-09-12, one user): after one CRM read, the next status questions were answered from held state under a receipt, 0 of 3 without a source; the [roadmap](ROADMAP.md) carries the gate table.
|
|
187
187
|
|
|
188
188
|
### The state contract, shipped
|
|
189
189
|
|
|
@@ -223,7 +223,7 @@ The current release can keep a team's **repository-scoped** memory in a dedicate
|
|
|
223
223
|
Today Hunch does not host that shared Git repository; teammates/CI use normal Git access and one maintainer connects it:
|
|
224
224
|
|
|
225
225
|
```bash
|
|
226
|
-
npm i -g @davesheffer/hunch@1.
|
|
226
|
+
npm i -g @davesheffer/hunch@1.32.2
|
|
227
227
|
hunch shared --repo git@github.com:acme/project-hunch-memory.git
|
|
228
228
|
git add .gitignore .hunch/team.json
|
|
229
229
|
git commit -m "chore: connect shared Hunch memory"
|
|
@@ -233,7 +233,7 @@ git push
|
|
|
233
233
|
Teammates then install the same version and run:
|
|
234
234
|
|
|
235
235
|
```bash
|
|
236
|
-
npm i -g @davesheffer/hunch@1.
|
|
236
|
+
npm i -g @davesheffer/hunch@1.32.2
|
|
237
237
|
git pull
|
|
238
238
|
hunch init
|
|
239
239
|
hunch doctor
|
|
@@ -264,6 +264,8 @@ The v1.19 correction-search benchmark remains scoped evidence, but it no longer
|
|
|
264
264
|
- **v1.21 — Project DNA.** Hunch gained deterministic, revision-specific repository profiles, bounded DNA context delivery, explainable Project Match checks and auditable profile deltas.
|
|
265
265
|
- **v1.22 — authorized collaboration evidence.** Hosts can contribute bounded PR/review evidence to Project DNA through a typed, sealed contract without raw collaboration persistence or policy-authority change.
|
|
266
266
|
- **v1.23 — native change proof and proof-carrying evidence work.** Exact Git change identity, graph before/after, decisions/constraints, blast radius and Change Gate result can be bound into a sealed evidence artifact without granting authority.
|
|
267
|
+
- **v1.25 – v1.30 — the state layer.** `nuryel.state/1` as code, `hunch serve` partitions, union reads, subject identity by external reference, replay determinism, human corrections outranking agent writes.
|
|
268
|
+
- **v1.32 — see what Hunch contributed.** Task contribution reports: what was delivered, what the agent reports it applied, what a rule verified, what a command observed; concise card, local evidence view.
|
|
267
269
|
|
|
268
270
|
See the [changelog](CHANGELOG.md) for release detail and the [roadmap](ROADMAP.md) for active work.
|
|
269
271
|
|
|
@@ -277,6 +279,9 @@ See the [changelog](CHANGELOG.md) for release detail and the [roadmap](ROADMAP.m
|
|
|
277
279
|
- [Project DNA](docs/project-dna.md)
|
|
278
280
|
- [Native change proof](docs/change-proof.md)
|
|
279
281
|
- [The autonomy ladder](docs/autonomy-ladder.md)
|
|
282
|
+
- [Task contribution reports](docs/task-reports.md)
|
|
283
|
+
- [Autonomous development](docs/autonomous-development.md)
|
|
284
|
+
- [Local cookbook](docs/cookbook.md)
|
|
280
285
|
- [Engineering Landscape Graph](docs/engineering-landscape.md)
|
|
281
286
|
- [Hunch roadmap](ROADMAP.md)
|
|
282
287
|
- [VS Code extension](vscode-extension/README.md)
|
package/dist/cli/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import { registerUpdateCommand } from "./update.js";
|
|
|
31
31
|
import { registerReviewMemoryCommands } from "./reviewMemory.js";
|
|
32
32
|
import { detectInitiator, normalizeInitiator } from "../synthesis/initiator.js";
|
|
33
33
|
import { inspectIntegrations, formatIntegrationHealth, integrationHealthFails, integrationSessionWarning } from "../integrations/health.js";
|
|
34
|
+
import { publishedStatus } from "../integrations/registry.js";
|
|
34
35
|
import { HunchStore } from "../store/hunchStore.js";
|
|
35
36
|
import { JsonStore } from "../store/jsonStore.js";
|
|
36
37
|
import { selectEmbedder } from "../store/embedder.js";
|
|
@@ -4451,15 +4452,21 @@ program
|
|
|
4451
4452
|
.option("--provider <provider>", "hook event dialect: claude | vscode | cursor | windsurf | antigravity", "claude")
|
|
4452
4453
|
.action(async (opts) => {
|
|
4453
4454
|
// A hook MUST NEVER break the agent: on ANY error or unrecognized input we
|
|
4454
|
-
//
|
|
4455
|
+
// exit 0 and never deny (the action defers to the host's normal flow).
|
|
4456
|
+
// Unrecognized input stays silent; an error after the event was recognized
|
|
4457
|
+
// says "grounding unavailable" in one context line — see the catch below.
|
|
4455
4458
|
let store = null;
|
|
4459
|
+
// Hoisted so the fail-open catch below can still say which grounding was lost.
|
|
4460
|
+
let provider = null;
|
|
4461
|
+
let eventName = null;
|
|
4456
4462
|
try {
|
|
4457
|
-
|
|
4463
|
+
provider = hookProvider(opts.provider);
|
|
4458
4464
|
if (!provider)
|
|
4459
4465
|
return;
|
|
4460
4466
|
const evt = normalizeHookEvent(JSON.parse(await readStdin()), provider);
|
|
4461
4467
|
if (!evt)
|
|
4462
4468
|
return;
|
|
4469
|
+
eventName = evt.hook_event_name;
|
|
4463
4470
|
const root = findRoot();
|
|
4464
4471
|
// The host delivered this event: runtime evidence for `hunch integrations check`,
|
|
4465
4472
|
// recorded before any policy decision so firmness never hides delivery itself.
|
|
@@ -4769,15 +4776,16 @@ program
|
|
|
4769
4776
|
}
|
|
4770
4777
|
// Pre-edit grounding must resolve the same advertised graph as every CLI
|
|
4771
4778
|
// and MCP consumer. Any unavailable/mismatched team route falls through to
|
|
4772
|
-
// the outer fail-open catch
|
|
4773
|
-
//
|
|
4779
|
+
// the outer fail-open catch: one "grounding unavailable" line, never a deny,
|
|
4780
|
+
// never grounding from public/stale memory (dec_77d99014e0).
|
|
4774
4781
|
const opened = openTeamStore(root, { requireFreshTeamMemory: firmness === "strict" });
|
|
4775
4782
|
store = opened.store;
|
|
4776
4783
|
if (firmness === "strict" && opened.teamPullStatus
|
|
4777
4784
|
&& opened.teamPullStatus !== "updated" && opened.teamPullStatus !== "current") {
|
|
4778
4785
|
// A strict deny is only trustworthy when it includes the latest team
|
|
4779
4786
|
// rules. Offline/busy/unconfigured team memory is unavailable, so the
|
|
4780
|
-
// non-blocking hook
|
|
4787
|
+
// non-blocking hook says so instead of denying from stale state.
|
|
4788
|
+
emitContext(provider, "PreToolUse", `Hunch grounding unavailable for this edit; it proceeds ungrounded (team memory is ${opened.teamPullStatus}; strict mode never denies from stale rules). Run \`hunch doctor\`.`);
|
|
4781
4789
|
return;
|
|
4782
4790
|
}
|
|
4783
4791
|
// strict: refuse an edit that hits a BLOCKING invariant (direct OR via blast
|
|
@@ -4900,8 +4908,18 @@ program
|
|
|
4900
4908
|
}
|
|
4901
4909
|
emitContext(provider, "PreToolUse", text + reportNotice, recalled ?? undefined);
|
|
4902
4910
|
}
|
|
4903
|
-
catch {
|
|
4904
|
-
//
|
|
4911
|
+
catch (e) {
|
|
4912
|
+
// Never block an edit on a hook failure — and never go silent either: an
|
|
4913
|
+
// ungrounded edit that looks grounded gets diagnosed as model flakiness.
|
|
4914
|
+
// One context line, exit 0 (the launcher itself failing stays out of reach).
|
|
4915
|
+
const reason = e instanceof Error ? e.message.split("\n")[0] : "unknown error";
|
|
4916
|
+
if (provider && (eventName === "PreToolUse" || eventName === "SessionStart" || eventName === "UserPromptSubmit")) {
|
|
4917
|
+
const what = eventName === "PreToolUse" ? "for this edit; it proceeds ungrounded" : "for this session";
|
|
4918
|
+
try {
|
|
4919
|
+
emitContext(provider, eventName, `Hunch grounding unavailable ${what} (${reason}). Run \`hunch doctor\`.`);
|
|
4920
|
+
}
|
|
4921
|
+
catch { /* stdout gone */ }
|
|
4922
|
+
}
|
|
4905
4923
|
}
|
|
4906
4924
|
finally {
|
|
4907
4925
|
store?.close();
|
|
@@ -6626,6 +6644,21 @@ program
|
|
|
6626
6644
|
.action(async () => {
|
|
6627
6645
|
const integrations = inspectIntegrations(findRoot());
|
|
6628
6646
|
console.log(formatIntegrationHealth(integrations));
|
|
6647
|
+
// A pin npm cannot serve kills every npx launcher (hooks and MCP) before Hunch
|
|
6648
|
+
// runs, and the hosts report nothing. Name it here; bounded, offline-safe.
|
|
6649
|
+
const published = new Map();
|
|
6650
|
+
for (const v of new Set([integrations.expectedVersion, ...integrations.pins.map(p => p.version)]))
|
|
6651
|
+
published.set(v, publishedStatus(v));
|
|
6652
|
+
const expectedUnpublished = published.get(integrations.expectedVersion) === "unpublished";
|
|
6653
|
+
for (const pin of integrations.pins) {
|
|
6654
|
+
if (published.get(pin.version) !== "unpublished")
|
|
6655
|
+
continue;
|
|
6656
|
+
console.log(`ERROR ${pin.file}: pins Hunch ${pin.version}, which npm cannot serve (ETARGET) — every hook run and MCP launch from this file fails before Hunch starts, silently. Run \`hunch integrations repair-pins\`${expectedUnpublished ? " once the release publishes" : ""}.`);
|
|
6657
|
+
process.exitCode = 1;
|
|
6658
|
+
}
|
|
6659
|
+
if (expectedUnpublished && integrations.pins.every(p => p.version !== integrations.expectedVersion)) {
|
|
6660
|
+
console.log(`note: package.json says ${integrations.expectedVersion}, which is not on npm yet; machine-local pins stay on the last published release until it is (then run \`hunch integrations repair-pins\`).`);
|
|
6661
|
+
}
|
|
6629
6662
|
// A shared-memory or CLI-only checkout may intentionally have no local
|
|
6630
6663
|
// assistant config. The explicit integrations check still fails that case.
|
|
6631
6664
|
if (integrations.harnesses.length > 0 && integrationHealthFails(integrations))
|
package/dist/cli/taskReport.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { findRoot } from "../core/paths.js";
|
|
3
3
|
import { writeFileAtomic } from "../core/io.js";
|
|
4
|
-
import { finishReportTask, forgetReportTask, listReportTasks, pruneReportHistory, readTaskReport, readLessonHistory, startReportTask } from "../core/taskReport.js";
|
|
4
|
+
import { finishReportTask, forgetReportTask, listReportTasks, listTaskSummaries, pruneReportHistory, readTaskReport, readLessonHistory, renderTaskStatusLine, startReportTask, summarizeTaskReport, taskReportStats } from "../core/taskReport.js";
|
|
5
|
+
import { promptTaskId } from "../core/taskReportHook.js";
|
|
5
6
|
import { DEFAULT_CHECK_TIMEOUT_MS, MAX_CHECK_TIMEOUT_MS, reportSourceSnapshot, runReportCheck, runReportConformance } from "../core/taskReportEvidence.js";
|
|
6
7
|
import { renderTaskReport, writeTaskReportHtml } from "../core/taskReportRender.js";
|
|
7
8
|
import { assertReportPath } from "../core/taskReportPaths.js";
|
|
@@ -40,6 +41,72 @@ export function registerTaskReportCommands(program, openStore) {
|
|
|
40
41
|
finishReportTask(root, id, opts.interrupted ? "interrupted" : "completed");
|
|
41
42
|
console.log(renderTaskReport(readTaskReport(root, id, reportSourceSnapshot(root).hash)));
|
|
42
43
|
});
|
|
44
|
+
task.command("list").description("Recent tasks observed in this repository with what Hunch delivered, saved, guarded, and checked")
|
|
45
|
+
.option("--limit <n>", "how many recent tasks (max 30)", "30")
|
|
46
|
+
.option("--json", "machine-readable summaries (consumed by the VS Code Contribution view)")
|
|
47
|
+
.action((opts) => {
|
|
48
|
+
const root = findRoot();
|
|
49
|
+
const summaries = listTaskSummaries(root, Number(opts.limit) || 30, reportSourceSnapshot(root).hash);
|
|
50
|
+
if (opts.json) {
|
|
51
|
+
console.log(JSON.stringify(summaries, null, 2));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!summaries.length) {
|
|
55
|
+
console.log("No task activity observed yet.");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
for (const s of summaries)
|
|
59
|
+
console.log(`${s.task.started_at.slice(0, 16).replace("T", " ")} ${s.task.task_id} ${s.task.state.padEnd(11)} ${renderTaskStatusLine(s) || "nothing observed"}`);
|
|
60
|
+
});
|
|
61
|
+
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")
|
|
62
|
+
.option("--days <days>", "window in days", "7")
|
|
63
|
+
.option("--json", "machine-readable")
|
|
64
|
+
.action((opts) => {
|
|
65
|
+
const stats = taskReportStats(findRoot(), Number(opts.days) || 7);
|
|
66
|
+
if (opts.json) {
|
|
67
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const pct = (n) => stats.tasks ? `${Math.round((n / stats.tasks) * 100)}%` : "–";
|
|
71
|
+
console.log(`Hunch adherence, last ${Number(opts.days) || 7} day(s): ${stats.tasks} task(s), ${stats.completed} completed`);
|
|
72
|
+
console.log(` reached by memory (delivery) ${stats.with_delivery} ${pct(stats.with_delivery)}`);
|
|
73
|
+
console.log(` independent check recorded ${stats.with_check} ${pct(stats.with_check)}`);
|
|
74
|
+
console.log(` application claimed by agent ${stats.with_claim} ${pct(stats.with_claim)}`);
|
|
75
|
+
console.log(` memory saved ${stats.with_save} ${pct(stats.with_save)}`);
|
|
76
|
+
console.log(` edit denied ${stats.with_refusal} ${pct(stats.with_refusal)}`);
|
|
77
|
+
console.log(` nothing observed ${stats.empty} ${pct(stats.empty)}`);
|
|
78
|
+
});
|
|
79
|
+
task.command("status").description("One line for a terminal status line: the current prompt's task when Claude Code's status-line JSON arrives on stdin, otherwise the most recent task here")
|
|
80
|
+
.option("--json", "machine-readable summary")
|
|
81
|
+
.action(async (opts) => {
|
|
82
|
+
const input = process.stdin.isTTY ? "" : await readStdinText();
|
|
83
|
+
let root = findRoot();
|
|
84
|
+
let taskId = null;
|
|
85
|
+
try {
|
|
86
|
+
const host = input.trim() ? JSON.parse(input) : {};
|
|
87
|
+
const dir = host.workspace?.current_dir ?? host.cwd;
|
|
88
|
+
if (dir)
|
|
89
|
+
root = findRoot(dir);
|
|
90
|
+
if (host.session_id && host.prompt_id)
|
|
91
|
+
taskId = promptTaskId(root, host.session_id, host.prompt_id);
|
|
92
|
+
}
|
|
93
|
+
catch { /* a malformed host payload falls back to the most recent task */ }
|
|
94
|
+
let summary = null;
|
|
95
|
+
try {
|
|
96
|
+
const snapshot = reportSourceSnapshot(root).hash;
|
|
97
|
+
summary = taskId ? summarizeTaskReport(root, taskId, snapshot) : listTaskSummaries(root, 1, snapshot)[0] ?? null;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
summary = null;
|
|
101
|
+
}
|
|
102
|
+
if (opts.json) {
|
|
103
|
+
console.log(JSON.stringify(summary));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const line = renderTaskStatusLine(summary);
|
|
107
|
+
if (line)
|
|
108
|
+
console.log(line);
|
|
109
|
+
});
|
|
43
110
|
task.command("forget <id>").description("Delete this closed task's local observations and generated report; retains project memory")
|
|
44
111
|
.action((id) => { forgetReportTask(findRoot(), id); console.log("Task report history removed; project memory retained."); });
|
|
45
112
|
task.command("prune").description("Delete local report history for tasks closed more than the retention period ago")
|
|
@@ -125,4 +192,16 @@ export function registerTaskReportCommands(program, openStore) {
|
|
|
125
192
|
console.log(opts.json ? JSON.stringify(report, null, 2) : renderTaskReport(report));
|
|
126
193
|
});
|
|
127
194
|
}
|
|
195
|
+
function readStdinText() {
|
|
196
|
+
return new Promise(resolve => {
|
|
197
|
+
let data = "";
|
|
198
|
+
const done = () => resolve(data);
|
|
199
|
+
process.stdin.setEncoding("utf8");
|
|
200
|
+
process.stdin.on("data", chunk => { data += chunk; });
|
|
201
|
+
process.stdin.on("end", done);
|
|
202
|
+
process.stdin.on("error", done);
|
|
203
|
+
// A host that opened stdin but never writes must not hang the status line.
|
|
204
|
+
setTimeout(done, 1500).unref();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
128
207
|
//# sourceMappingURL=taskReport.js.map
|
package/dist/core/agenthook.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* fields or tools. Keep that variability here so the policy engine receives
|
|
6
6
|
* the same small, fail-open shape regardless of the assistant that emitted it.
|
|
7
7
|
*/
|
|
8
|
-
export declare const HOOK_PROVIDERS: readonly ["claude", "vscode", "windsurf", "antigravity", "cursor"];
|
|
8
|
+
export declare const HOOK_PROVIDERS: readonly ["claude", "codex", "vscode", "windsurf", "antigravity", "cursor"];
|
|
9
9
|
export type HookProvider = (typeof HOOK_PROVIDERS)[number];
|
|
10
10
|
export type HunchHookEvent = "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "UserPromptSubmit" | "SessionStart" | "SubagentStart" | "PreCompact" | "Stop";
|
|
11
11
|
export interface HunchToolInput {
|
package/dist/core/agenthook.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* fields or tools. Keep that variability here so the policy engine receives
|
|
6
6
|
* the same small, fail-open shape regardless of the assistant that emitted it.
|
|
7
7
|
*/
|
|
8
|
-
export const HOOK_PROVIDERS = ["claude", "vscode", "windsurf", "antigravity", "cursor"];
|
|
8
|
+
export const HOOK_PROVIDERS = ["claude", "codex", "vscode", "windsurf", "antigravity", "cursor"];
|
|
9
9
|
function obj(value) {
|
|
10
10
|
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
11
11
|
}
|
|
@@ -49,10 +49,24 @@ function edits(value) {
|
|
|
49
49
|
.map((item) => ({ new_string: stringAt(item, "new_string", "newString", "ReplacementContent", "replacementContent") }));
|
|
50
50
|
return normalized.length ? normalized : undefined;
|
|
51
51
|
}
|
|
52
|
+
/** Codex edits files through `apply_patch`, whose input is the patch text itself
|
|
53
|
+
* (`*** Update File: path`). The first touched path becomes the edit target so the
|
|
54
|
+
* per-file pre-edit gate applies; the whole patch stands in for the new content. */
|
|
55
|
+
const PATCH_FILE = /^\*\*\* (?:Update|Add|Delete) File: (.+?)\s*$/m;
|
|
56
|
+
function applyPatchInput(raw) {
|
|
57
|
+
const patch = [raw.input, raw.patch, raw.content].find((v) => typeof v === "string" && /\*\*\* Begin Patch/.test(v));
|
|
58
|
+
if (!patch)
|
|
59
|
+
return undefined;
|
|
60
|
+
const file = PATCH_FILE.exec(patch)?.[1];
|
|
61
|
+
return file ? { file_path: file, content: patch } : undefined;
|
|
62
|
+
}
|
|
52
63
|
function normalizeToolInput(value) {
|
|
53
64
|
const raw = obj(value);
|
|
54
65
|
if (!raw)
|
|
55
66
|
return undefined;
|
|
67
|
+
const patched = applyPatchInput(raw);
|
|
68
|
+
if (patched)
|
|
69
|
+
return patched;
|
|
56
70
|
const replacementChunks = Array.isArray(raw.ReplacementChunks) ? raw.ReplacementChunks : raw.replacementChunks;
|
|
57
71
|
const chunkEdits = Array.isArray(replacementChunks)
|
|
58
72
|
? replacementChunks.map((chunk) => obj(chunk)).filter((chunk) => !!chunk)
|
|
@@ -63,7 +77,9 @@ function normalizeToolInput(value) {
|
|
|
63
77
|
new_string: stringAt(raw, "new_string", "newString", "ReplacementContent", "replacementContent", "TargetContent", "targetContent"),
|
|
64
78
|
content: stringAt(raw, "content", "contents", "CodeContent", "codeContent"),
|
|
65
79
|
edits: edits(raw.edits) ?? edits(raw.files) ?? chunkEdits,
|
|
66
|
-
|
|
80
|
+
// Codex's shell tools carry argv arrays (["bash", "-lc", "…"]); policies read one string.
|
|
81
|
+
command: stringAt(raw, "command", "commandLine", "CommandLine", "cmd")
|
|
82
|
+
?? (Array.isArray(raw.command) && raw.command.every(p => typeof p === "string") ? raw.command.join(" ") : undefined),
|
|
67
83
|
skill: stringAt(raw, "skill", "skillName", "name"),
|
|
68
84
|
};
|
|
69
85
|
return Object.values(out).some((v) => v !== undefined) ? out : undefined;
|
|
@@ -194,7 +210,10 @@ export function normalizeHookEvent(raw, provider) {
|
|
|
194
210
|
return {
|
|
195
211
|
hook_event_name: event,
|
|
196
212
|
session_id: stringAt(input, "session_id", "sessionId", "conversation_id", "conversationId"),
|
|
197
|
-
|
|
213
|
+
// Codex delivers the same lifecycle payload with `turn_id` where Claude Code
|
|
214
|
+
// says `prompt_id`; both are native per-prompt identities, never synthesized.
|
|
215
|
+
...(provider === "codex" && input.prompt_id === undefined && input.turn_id !== undefined ? { prompt_id: typeof input.turn_id === "string" ? input.turn_id : "" } : {}),
|
|
216
|
+
...(provider === "claude" || provider === "codex" ? Object.fromEntries(["prompt_id", "cwd", "agent_id"].filter(key => input[key] !== undefined).map(key => [key, typeof input[key] === "string" ? input[key] : ""])) : {}),
|
|
198
217
|
tool_name: hunchToolName(stringAt(input, "tool_name", "toolName"), toolInput ?? {}),
|
|
199
218
|
tool_input: toolInput,
|
|
200
219
|
...(toolOutcome ? { tool_outcome: toolOutcome } : {}),
|
package/dist/core/config.d.ts
CHANGED
|
@@ -11,6 +11,9 @@ export declare const FIRMNESS_LEVELS: readonly Firmness[];
|
|
|
11
11
|
export declare const DEFAULT_FIRMNESS: Firmness;
|
|
12
12
|
export interface HunchConfig {
|
|
13
13
|
firmness: Firmness;
|
|
14
|
+
/** MCP tool groups beyond the everyday set: `all`, `core`, or `core,nuryel`
|
|
15
|
+
* (see src/mcp/toolset.ts). Undefined = decide from the root's contents. */
|
|
16
|
+
mcp_tools?: string;
|
|
14
17
|
}
|
|
15
18
|
export declare function isFirmness(v: unknown): v is Firmness;
|
|
16
19
|
/** Read `.hunch/config.json`. A missing/unparseable file, or an unknown firmness
|
package/dist/core/config.js
CHANGED
|
@@ -19,7 +19,10 @@ export function readConfig(paths) {
|
|
|
19
19
|
return defaults();
|
|
20
20
|
try {
|
|
21
21
|
const raw = JSON.parse(readFileSync(paths.config, "utf8"));
|
|
22
|
-
return {
|
|
22
|
+
return {
|
|
23
|
+
firmness: isFirmness(raw.firmness) ? raw.firmness : DEFAULT_FIRMNESS,
|
|
24
|
+
...(typeof raw.mcp_tools === "string" && raw.mcp_tools.trim() ? { mcp_tools: raw.mcp_tools.trim() } : {}),
|
|
25
|
+
};
|
|
23
26
|
}
|
|
24
27
|
catch {
|
|
25
28
|
return defaults();
|
|
@@ -961,6 +961,9 @@ export declare const STATE_INVARIANTS: readonly [{
|
|
|
961
961
|
}, {
|
|
962
962
|
readonly id: "derived-state-writer-owns-currentness";
|
|
963
963
|
readonly statement: "No source writes the drawer. The writer of a current derived statement owns keeping its dependencies true: re-validate them on a schedule or on a source event, and write the statement back stale with the moved pointer as cause when one no longer holds. Without this duty an agent may capture source-backed observations only as unknown; observations never assert currentness.";
|
|
964
|
+
}, {
|
|
965
|
+
readonly id: "one-current-derived-per-subject-transform";
|
|
966
|
+
readonly statement: "A subject holds at most one current derived statement per transform: a new one must name the incumbent in supersedes (refused 409 with the incumbent named otherwise); the same identity written again updates or replays that record.";
|
|
964
967
|
}];
|
|
965
968
|
/** The memory supply chain's top tier: a record whose provenance a human signed. Same tier rule
|
|
966
969
|
* as the strict gate's (strictgate.isHumanConfirmed), applied to a record instead of a source. */
|
|
@@ -306,6 +306,7 @@ export const STATE_INVARIANTS = [
|
|
|
306
306
|
{ id: "one-entity-per-external-ref", statement: "One external record is one entity in a partition: a second active entity carrying an external key an incumbent already carries is refused with the incumbent named, and a subject written as that record's external key is refused with the entity's id named. Identity is explicit refs, never similarity; merge is explicit — a retired entity names the survivor in `merged_into`, the ledger holds the `retired` event, nothing under the old id is rewritten and reads resolve to the survivor — and split is the explicit reverse; never a silent rewrite." },
|
|
307
307
|
{ id: "human-correction-outranks-agent-writes", statement: "A record a human confirmed is never overwritten or superseded by an agent or service principal: the agent may replay it, write derived state back stale with the external cause that moved, or close a commitment with a receipt on record. Changing what the human said takes a human." },
|
|
308
308
|
{ id: "derived-state-writer-owns-currentness", statement: "No source writes the drawer. The writer of a current derived statement owns keeping its dependencies true: re-validate them on a schedule or on a source event, and write the statement back stale with the moved pointer as cause when one no longer holds. Without this duty an agent may capture source-backed observations only as unknown; observations never assert currentness." },
|
|
309
|
+
{ id: "one-current-derived-per-subject-transform", statement: "A subject holds at most one current derived statement per transform: a new one must name the incumbent in supersedes (refused 409 with the incumbent named otherwise); the same identity written again updates or replays that record." },
|
|
309
310
|
];
|
|
310
311
|
const grantKey = (scope) => scopePath(scope);
|
|
311
312
|
/** The memory supply chain's top tier: a record whose provenance a human signed. Same tier rule
|
|
@@ -222,6 +222,55 @@ export declare function recordReportConformance(root: string, taskId: string, co
|
|
|
222
222
|
export declare function recordReportCheck(root: string, taskId: string, check: ReportCheck): string;
|
|
223
223
|
export declare function beginReportCheck(root: string, taskId: string, label: string): string;
|
|
224
224
|
export declare function finishReportTask(root: string, taskId: string, state?: "completed" | "interrupted"): ReportTask;
|
|
225
|
+
/** A report with no observation of any kind. Presentation surfaces may stay
|
|
226
|
+
* silent for it; the task row itself is retained so "never touched Hunch" is
|
|
227
|
+
* still countable (hunch report / the VS Code view / task list). */
|
|
228
|
+
export declare function isEmptyTaskReport(report: Pick<TaskReport, "deliveries" | "claims" | "checks" | "conformance" | "saves" | "refusals">): boolean;
|
|
229
|
+
export interface TaskSummary {
|
|
230
|
+
task: ReportTask;
|
|
231
|
+
deliveries: number;
|
|
232
|
+
lessons: number;
|
|
233
|
+
claims: number;
|
|
234
|
+
saves: number;
|
|
235
|
+
refusals: number;
|
|
236
|
+
/** Standing of the last recorded check, or null when none ran. */
|
|
237
|
+
check: {
|
|
238
|
+
label: string;
|
|
239
|
+
state: "passed" | "failed" | "timed out" | "cancelled";
|
|
240
|
+
current: boolean;
|
|
241
|
+
} | null;
|
|
242
|
+
/** Any delivered rule evaluated as violated on the changed files. */
|
|
243
|
+
violated: boolean;
|
|
244
|
+
coverage: TaskReport["coverage"];
|
|
245
|
+
empty: boolean;
|
|
246
|
+
/** Generated evidence view, when one has been written for this task. */
|
|
247
|
+
report_html: string | null;
|
|
248
|
+
/** Set when the observation ledger could not be read for this task. */
|
|
249
|
+
error: string | null;
|
|
250
|
+
}
|
|
251
|
+
/** One bounded summary per recent task for status lines and host views; the
|
|
252
|
+
* card and evidence view remain the authoritative renderings. */
|
|
253
|
+
export declare function summarizeTaskReport(root: string, taskId: string, currentSnapshot?: string | null): TaskSummary;
|
|
254
|
+
export declare function listTaskSummaries(root: string, limit?: number, currentSnapshot?: string | null): TaskSummary[];
|
|
255
|
+
/** One line for a terminal status line. Empty string when nothing was observed
|
|
256
|
+
* for the task, so a bare prompt shows no Hunch noise at all. */
|
|
257
|
+
export declare function renderTaskStatusLine(summary: TaskSummary | null): string;
|
|
258
|
+
export interface TaskReportStats {
|
|
259
|
+
since: string;
|
|
260
|
+
tasks: number;
|
|
261
|
+
completed: number;
|
|
262
|
+
with_delivery: number;
|
|
263
|
+
with_check: number;
|
|
264
|
+
with_claim: number;
|
|
265
|
+
with_save: number;
|
|
266
|
+
with_refusal: number;
|
|
267
|
+
empty: number;
|
|
268
|
+
/** with_delivery / tasks, the adherence number worth watching; null when no tasks. */
|
|
269
|
+
delivery_rate: number | null;
|
|
270
|
+
}
|
|
271
|
+
/** Adherence over a window: how many prompts Hunch actually reached. Counts
|
|
272
|
+
* come from the ledger, never from agent claims; a claim is counted as a claim. */
|
|
273
|
+
export declare function taskReportStats(root: string, days?: number): TaskReportStats;
|
|
225
274
|
export declare function listReportTasks(root: string): ReportTask[];
|
|
226
275
|
export declare function reportActivity(root: string): string;
|
|
227
276
|
/** Exact task deletion is a user-invoked operation, never a memory deletion. */
|
package/dist/core/taskReport.js
CHANGED
|
@@ -368,6 +368,105 @@ export function finishReportTask(root, taskId, state = "completed") {
|
|
|
368
368
|
return finished;
|
|
369
369
|
}));
|
|
370
370
|
}
|
|
371
|
+
/** A report with no observation of any kind. Presentation surfaces may stay
|
|
372
|
+
* silent for it; the task row itself is retained so "never touched Hunch" is
|
|
373
|
+
* still countable (hunch report / the VS Code view / task list). */
|
|
374
|
+
export function isEmptyTaskReport(report) {
|
|
375
|
+
return !report.deliveries.length && !report.claims.length && !report.checks.length && !report.conformance.length && !report.saves.length && !report.refusals.length;
|
|
376
|
+
}
|
|
377
|
+
/** One bounded summary per recent task for status lines and host views; the
|
|
378
|
+
* card and evidence view remain the authoritative renderings. */
|
|
379
|
+
export function summarizeTaskReport(root, taskId, currentSnapshot = null) {
|
|
380
|
+
const html = join(root, ".hunch-cache", "reports", `${taskId}.html`);
|
|
381
|
+
try {
|
|
382
|
+
const report = readTaskReport(root, taskId, currentSnapshot);
|
|
383
|
+
const last = report.checks.at(-1);
|
|
384
|
+
const standing = new Map(report.conformance.map(r => [`${r.kind}:${r.record_id}:${r.content_hash}`, r]));
|
|
385
|
+
return {
|
|
386
|
+
task: report.task,
|
|
387
|
+
deliveries: report.deliveries.length,
|
|
388
|
+
lessons: new Set(report.deliveries.flatMap(d => d.records).map(r => `${r.kind}:${r.record_id}`)).size,
|
|
389
|
+
claims: report.claims.length,
|
|
390
|
+
saves: report.saves.length,
|
|
391
|
+
refusals: report.refusals.length,
|
|
392
|
+
check: last ? { label: last.label, state: last.cancelled ? "cancelled" : last.timed_out ? "timed out" : last.exit_code === 0 ? "passed" : "failed", current: last.current } : null,
|
|
393
|
+
violated: [...standing.values()].some(r => r.outcome === "violated"),
|
|
394
|
+
coverage: report.coverage,
|
|
395
|
+
empty: isEmptyTaskReport(report),
|
|
396
|
+
report_html: existsSync(html) ? html : null,
|
|
397
|
+
error: null,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
catch (e) {
|
|
401
|
+
return taskDb(root, db => {
|
|
402
|
+
const row = db.prepare("SELECT body FROM report_tasks WHERE task_id = ?").get(taskId);
|
|
403
|
+
if (!row)
|
|
404
|
+
throw e;
|
|
405
|
+
return { task: TaskSchema.parse(JSON.parse(row.body)), deliveries: 0, lessons: 0, claims: 0, saves: 0, refusals: 0, check: null, violated: false, coverage: "no-delivery-observed", empty: true, report_html: existsSync(html) ? html : null, error: e.message };
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
export function listTaskSummaries(root, limit = 30, currentSnapshot = null) {
|
|
410
|
+
if (!existsSync(join(root, ".hunch-cache", "served.db")))
|
|
411
|
+
return [];
|
|
412
|
+
return listReportTasks(root).slice(0, Math.max(1, Math.min(limit, 30))).map(task => summarizeTaskReport(root, task.task_id, currentSnapshot));
|
|
413
|
+
}
|
|
414
|
+
/** One line for a terminal status line. Empty string when nothing was observed
|
|
415
|
+
* for the task, so a bare prompt shows no Hunch noise at all. */
|
|
416
|
+
export function renderTaskStatusLine(summary) {
|
|
417
|
+
if (!summary || summary.empty)
|
|
418
|
+
return "";
|
|
419
|
+
const parts = [`Hunch`];
|
|
420
|
+
parts.push(summary.lessons ? `${summary.lessons} lesson${summary.lessons === 1 ? "" : "s"} recalled` : summary.deliveries ? "memory delivered" : "no delivery");
|
|
421
|
+
if (summary.violated)
|
|
422
|
+
parts.push("rule violated");
|
|
423
|
+
else if (summary.claims)
|
|
424
|
+
parts.push(`${summary.claims} applied`);
|
|
425
|
+
if (summary.saves)
|
|
426
|
+
parts.push(`${summary.saves} saved`);
|
|
427
|
+
if (summary.refusals)
|
|
428
|
+
parts.push("edit denied");
|
|
429
|
+
if (summary.check)
|
|
430
|
+
parts.push(`${summary.check.label}: ${summary.check.state}${summary.check.current ? "" : " (source changed)"}`);
|
|
431
|
+
else
|
|
432
|
+
parts.push("no check recorded");
|
|
433
|
+
return parts.join(" · ");
|
|
434
|
+
}
|
|
435
|
+
/** Adherence over a window: how many prompts Hunch actually reached. Counts
|
|
436
|
+
* come from the ledger, never from agent claims; a claim is counted as a claim. */
|
|
437
|
+
export function taskReportStats(root, days = 7) {
|
|
438
|
+
const since = new Date(Date.now() - Math.max(1, days) * 86_400_000).toISOString();
|
|
439
|
+
const empty = { since, tasks: 0, completed: 0, with_delivery: 0, with_check: 0, with_claim: 0, with_save: 0, with_refusal: 0, empty: 0, delivery_rate: null };
|
|
440
|
+
if (!existsSync(join(root, ".hunch-cache", "served.db")))
|
|
441
|
+
return empty;
|
|
442
|
+
return taskDb(root, db => {
|
|
443
|
+
const tasks = db.prepare("SELECT body FROM report_tasks WHERE scope = ? AND json_extract(body, '$.started_at') >= ?").all(scopeOf(root), since)
|
|
444
|
+
.map(r => TaskSchema.parse(JSON.parse(r.body)));
|
|
445
|
+
if (!tasks.length)
|
|
446
|
+
return empty;
|
|
447
|
+
const kinds = (taskId) => new Set(db.prepare("SELECT DISTINCT kind FROM report_events WHERE task_id = ?").all(taskId).map(r => r.kind));
|
|
448
|
+
const stats = { ...empty, tasks: tasks.length };
|
|
449
|
+
for (const task of tasks) {
|
|
450
|
+
const k = kinds(task.task_id);
|
|
451
|
+
if (task.state === "completed")
|
|
452
|
+
stats.completed++;
|
|
453
|
+
if (k.has("delivery"))
|
|
454
|
+
stats.with_delivery++;
|
|
455
|
+
if (k.has("check") || k.has("check-start"))
|
|
456
|
+
stats.with_check++;
|
|
457
|
+
if (k.has("claim"))
|
|
458
|
+
stats.with_claim++;
|
|
459
|
+
if (k.has("save"))
|
|
460
|
+
stats.with_save++;
|
|
461
|
+
if (k.has("refusal"))
|
|
462
|
+
stats.with_refusal++;
|
|
463
|
+
if (!k.size)
|
|
464
|
+
stats.empty++;
|
|
465
|
+
}
|
|
466
|
+
stats.delivery_rate = stats.with_delivery / stats.tasks;
|
|
467
|
+
return stats;
|
|
468
|
+
});
|
|
469
|
+
}
|
|
371
470
|
export function listReportTasks(root) {
|
|
372
471
|
return taskDb(root, db => db.prepare("SELECT body FROM report_tasks WHERE scope = ? ORDER BY rowid DESC LIMIT 30").all(scopeOf(root)).map(r => TaskSchema.parse(JSON.parse(r.body))));
|
|
373
472
|
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { HookProvider, HunchHookInput } from "./agenthook.js";
|
|
2
|
+
/** The exact task identity a Claude Code prompt maps to. The status line receives
|
|
3
|
+
* the same session_id/prompt_id on stdin, so it can name the prompt's task too. */
|
|
4
|
+
export declare function promptTaskId(root: string, sessionId: string, promptId: string, agentId?: string | null, provider?: HookProvider): string;
|
|
2
5
|
export declare function hookReportTaskId(root: string, provider: HookProvider, event: HunchHookInput): string | null;
|
|
3
6
|
/** Every prompt receives its exact ID, even when ambient reminders were deduped.
|
|
4
7
|
* No raw prompt, host session identifier, or transcript is retained. */
|
|
5
8
|
export declare function startHookReport(root: string, provider: HookProvider, event: HunchHookInput): string | null;
|
|
6
9
|
/** A presentation notice never denies Stop or injects another model turn. Stop
|
|
7
|
-
* can precede another hook's continuation, so it does not close an open task.
|
|
10
|
+
* can precede another hook's continuation, so it does not close an open task.
|
|
11
|
+
* A prompt with no observation at all prints nothing: the empty task row stays
|
|
12
|
+
* in the ledger (hunch task list, the VS Code Contribution view) so "never
|
|
13
|
+
* touched Hunch" remains countable without a five-line notice per prompt. */
|
|
8
14
|
export declare function stopHookReport(root: string, provider: HookProvider, event: HunchHookInput): {
|
|
9
15
|
systemMessage: string;
|
|
10
16
|
} | null;
|
|
@@ -3,11 +3,19 @@
|
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { findRoot } from "./paths.js";
|
|
6
|
-
import { readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, startReportTask } from "./taskReport.js";
|
|
6
|
+
import { isEmptyTaskReport, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, startReportTask } from "./taskReport.js";
|
|
7
7
|
import { reportSourceSnapshot } from "./taskReportEvidence.js";
|
|
8
8
|
import { renderTaskReport, writeTaskReportHtml } from "./taskReportRender.js";
|
|
9
|
+
/** The exact task identity a Claude Code prompt maps to. The status line receives
|
|
10
|
+
* the same session_id/prompt_id on stdin, so it can name the prompt's task too. */
|
|
11
|
+
export function promptTaskId(root, sessionId, promptId, agentId = null, provider = "claude") {
|
|
12
|
+
return `htask_${reportHash([realpathSync(root), provider, sessionId, promptId, agentId]).slice(7, 31)}`;
|
|
13
|
+
}
|
|
14
|
+
/** Hosts whose hooks deliver a native per-prompt identity (Claude Code's
|
|
15
|
+
* prompt_id, Codex's turn_id). Others get no task from a hook. */
|
|
16
|
+
const NATIVE_PROMPT_HOSTS = new Set(["claude", "codex"]);
|
|
9
17
|
function identity(root, provider, event) {
|
|
10
|
-
if (provider
|
|
18
|
+
if (!NATIVE_PROMPT_HOSTS.has(provider) || !event.cwd || realpathSync(findRoot(event.cwd)) !== realpathSync(root))
|
|
11
19
|
return null;
|
|
12
20
|
for (const value of [event.session_id, event.prompt_id, event.agent_id]) {
|
|
13
21
|
if (value !== undefined && (!value.length || value.length > 1024 || /[\u0000-\u001f\u007f]/.test(value)))
|
|
@@ -17,7 +25,7 @@ function identity(root, provider, event) {
|
|
|
17
25
|
return null;
|
|
18
26
|
if (!event.prompt_id)
|
|
19
27
|
return "legacy";
|
|
20
|
-
return
|
|
28
|
+
return promptTaskId(root, event.session_id, event.prompt_id, event.agent_id ?? null, provider);
|
|
21
29
|
}
|
|
22
30
|
export function hookReportTaskId(root, provider, event) {
|
|
23
31
|
try {
|
|
@@ -38,7 +46,10 @@ export function startHookReport(root, provider, event) {
|
|
|
38
46
|
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: "Claude task") to obtain verification_argv; do not create another report. Pass this task_id to hunch_context and decision/correction/finding captures, and finish with hunch_task before responding. A host Stop notice will show the evidence even if no task-linked memory was observed.`;
|
|
39
47
|
}
|
|
40
48
|
/** A presentation notice never denies Stop or injects another model turn. Stop
|
|
41
|
-
* can precede another hook's continuation, so it does not close an open task.
|
|
49
|
+
* can precede another hook's continuation, so it does not close an open task.
|
|
50
|
+
* A prompt with no observation at all prints nothing: the empty task row stays
|
|
51
|
+
* in the ledger (hunch task list, the VS Code Contribution view) so "never
|
|
52
|
+
* touched Hunch" remains countable without a five-line notice per prompt. */
|
|
42
53
|
export function stopHookReport(root, provider, event) {
|
|
43
54
|
if (!reportPresentationEnabled(root))
|
|
44
55
|
return null;
|
|
@@ -46,9 +57,11 @@ export function stopHookReport(root, provider, event) {
|
|
|
46
57
|
if (!id)
|
|
47
58
|
return null;
|
|
48
59
|
if (id === "legacy")
|
|
49
|
-
return { systemMessage: "Hunch hook active. This
|
|
60
|
+
return { systemMessage: "Hunch hook active. This host version does not provide an exact prompt identifier, so contribution for this response is unverified. Explicit task reports remain available with hunch report." };
|
|
50
61
|
try {
|
|
51
62
|
const report = readTaskReport(root, id, reportSourceSnapshot(root).hash);
|
|
63
|
+
if (isEmptyTaskReport(report))
|
|
64
|
+
return null;
|
|
52
65
|
let card = renderTaskReport(report);
|
|
53
66
|
try {
|
|
54
67
|
const file = writeTaskReportHtml(root, id);
|