@davesheffer/hunch 1.32.3 → 1.32.5
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 +1 -1
- package/dist/cli/index.js +40 -7
- package/dist/cli/taskReport.js +80 -1
- package/dist/constitution/behaviorEvaluator.js +1 -1
- package/dist/constitution/repository.d.ts +1 -0
- package/dist/constitution/repository.js +68 -42
- package/dist/core/agenthook.d.ts +1 -1
- package/dist/core/agenthook.js +30 -6
- package/dist/core/checkreport.js +1 -1
- package/dist/core/config.d.ts +3 -0
- package/dist/core/config.js +4 -1
- package/dist/core/events.js +19 -3
- package/dist/core/io.js +30 -19
- package/dist/core/jsonc.js +13 -3
- package/dist/core/storeArtifact.d.ts +7 -0
- package/dist/core/storeArtifact.js +62 -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/integrations/claudeConfig.js +20 -3
- package/dist/integrations/claudemd.js +1 -1
- package/dist/integrations/health.d.ts +19 -5
- package/dist/integrations/health.js +36 -11
- package/dist/integrations/providers.d.ts +7 -0
- package/dist/integrations/providers.js +85 -16
- package/dist/integrations/registry.d.ts +15 -0
- package/dist/integrations/registry.js +41 -0
- package/dist/integrations/scaffold.js +17 -3
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.js +350 -324
- package/dist/mcp/toolset.d.ts +30 -0
- package/dist/mcp/toolset.js +72 -0
- package/dist/serve/app.js +9 -6
- package/dist/serve/writelock.js +8 -2
- package/dist/store/changeLedger.js +7 -6
- package/dist/store/jsonStore.d.ts +3 -3
- package/dist/store/jsonStore.js +65 -14
- 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
|
|
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
|
|
@@ -130,7 +130,7 @@ export function evaluateExecutableBehaviorPolicy(root, policy, opts = {}) {
|
|
|
130
130
|
// longer match the commit's dependency inputs. Name each with its recovery;
|
|
131
131
|
// both stay `error`, never a coerced pass.
|
|
132
132
|
if (!existsSync(join(root, ".hunch-cache", "behavior-deps"))) {
|
|
133
|
-
return evaluation(policy, commit, { ...baseExecution, commit, error_code: "dependency-snapshot-cache-absent" }, "error", "no dependency snapshot cache exists on this machine (.hunch-cache/behavior-deps); executable behavior is unevaluated here, not failed — provision the policy's snapshots (hunch constitution
|
|
133
|
+
return evaluation(policy, commit, { ...baseExecution, commit, error_code: "dependency-snapshot-cache-absent" }, "error", "no dependency snapshot cache exists on this machine (.hunch-cache/behavior-deps); executable behavior is unevaluated here, not failed — provision the policy's snapshots (hunch constitution g2 --behavior-deps <candidate> --behavior-review-hash <hash>) or evaluate where they were built");
|
|
134
134
|
}
|
|
135
135
|
return evaluation(policy, commit, { ...baseExecution, commit, error_code: "dependency-snapshot-unavailable" }, "error", `no unique exact dependency snapshot matches this commit's package.json/package-lock.json among the policy's pinned ids (${assertion.dependency_snapshot_ids.join(", ")}); dependency inputs changed since compilation — re-plan and re-prove the policy (rb_g2_stale_policy_01)`);
|
|
136
136
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync,
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import { writeFileAtomic, writeFileAtomicIfAbsent } from "../core/io.js";
|
|
4
|
+
import { readStoreArtifact, storeArtifactPath } from "../core/storeArtifact.js";
|
|
4
5
|
import { shortHash } from "../core/ids.js";
|
|
5
6
|
import { canonicalHash, policySemanticHash, proofPlanContentHash } from "./canonical.js";
|
|
6
7
|
import { proofCorpusContentHash } from "./corpus.js";
|
|
@@ -9,13 +10,40 @@ import { assertCompositionBinding, compositionDescendants, policyProofHash } fro
|
|
|
9
10
|
import { currentShadowDispositions, policyEvaluationContentHash, shadowDispositionContentHash, shadowDispositionJudgmentHash, shadowEvaluationContentHash, shadowEvaluationIdentityHash, } from "./shadow.js";
|
|
10
11
|
import { HistoryDispositionSchema, ProofCorpusSchema, PolicyProofSchema, ProofPlanSchema, PolicySpecSchema, ShadowRecordSchema, EvidenceEventSchema, } from "./schema.js";
|
|
11
12
|
const encode = (value) => JSON.stringify(value, null, 2) + "\n";
|
|
13
|
+
const MAX_POLICY_ARTIFACT_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
function assertPolicyArtifactSize(data) {
|
|
15
|
+
if (Buffer.byteLength(data, "utf8") > MAX_POLICY_ARTIFACT_BYTES) {
|
|
16
|
+
throw new Error(`policy artifact exceeds the ${MAX_POLICY_ARTIFACT_BYTES}-byte limit`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function artifactFile(dir, name) {
|
|
20
|
+
return storeArtifactPath(dir, name);
|
|
21
|
+
}
|
|
22
|
+
function writeArtifact(dir, name, data) {
|
|
23
|
+
assertPolicyArtifactSize(data);
|
|
24
|
+
const file = artifactFile(dir, name);
|
|
25
|
+
writeFileAtomic(file, data);
|
|
26
|
+
artifactFile(dir, name);
|
|
27
|
+
}
|
|
28
|
+
function writeArtifactIfAbsent(dir, name, data) {
|
|
29
|
+
assertPolicyArtifactSize(data);
|
|
30
|
+
const file = artifactFile(dir, name);
|
|
31
|
+
const created = writeFileAtomicIfAbsent(file, data);
|
|
32
|
+
if (created)
|
|
33
|
+
artifactFile(dir, name);
|
|
34
|
+
return created;
|
|
35
|
+
}
|
|
12
36
|
function loadRecords(dir, parse, label) {
|
|
13
|
-
|
|
37
|
+
const safeDir = storeArtifactPath(dirname(dir), basename(dir));
|
|
38
|
+
if (!existsSync(safeDir))
|
|
14
39
|
return [];
|
|
15
40
|
const out = [];
|
|
16
|
-
for (const name of readdirSync(
|
|
41
|
+
for (const name of readdirSync(safeDir).filter((n) => n.endsWith(".json")).sort()) {
|
|
17
42
|
try {
|
|
18
|
-
|
|
43
|
+
const raw = readStoreArtifact(safeDir, [name], MAX_POLICY_ARTIFACT_BYTES);
|
|
44
|
+
if (raw === null)
|
|
45
|
+
throw new Error("record disappeared while it was being read");
|
|
46
|
+
out.push(parse(JSON.parse(raw)));
|
|
19
47
|
}
|
|
20
48
|
catch (e) {
|
|
21
49
|
// A policy store can control CI. Skipping a corrupt record would turn an
|
|
@@ -41,7 +69,12 @@ export class PolicyRepository {
|
|
|
41
69
|
const base = home === "private" ? this.privateHome : this.publicHome;
|
|
42
70
|
if (!base)
|
|
43
71
|
throw new Error("No private Hunch overlay is configured; refusing to write a private policy.");
|
|
44
|
-
return
|
|
72
|
+
return storeArtifactPath(base, kind);
|
|
73
|
+
}
|
|
74
|
+
ensureDir(home, kind) {
|
|
75
|
+
const dir = this.dir(home, kind);
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
return storeArtifactPath(dirname(dir), basename(dir));
|
|
45
78
|
}
|
|
46
79
|
policiesIn(home) {
|
|
47
80
|
if (home === "private" && !this.privateHome)
|
|
@@ -200,9 +233,9 @@ export class PolicyRepository {
|
|
|
200
233
|
return dispositions.sort((left, right) => left.id.localeCompare(right.id));
|
|
201
234
|
}
|
|
202
235
|
homeOfPolicy(id) {
|
|
203
|
-
if (this.privateHome && existsSync(
|
|
236
|
+
if (this.privateHome && existsSync(artifactFile(this.dir("private", "policies"), `${id}.json`)))
|
|
204
237
|
return "private";
|
|
205
|
-
if (existsSync(
|
|
238
|
+
if (existsSync(artifactFile(this.dir("public", "policies"), `${id}.json`)))
|
|
206
239
|
return "public";
|
|
207
240
|
return undefined;
|
|
208
241
|
}
|
|
@@ -213,9 +246,8 @@ export class PolicyRepository {
|
|
|
213
246
|
throw new Error(`refusing to write ${parsed.data_class} policy ${parsed.id} into its existing public home; migrate it to the private overlay first`);
|
|
214
247
|
}
|
|
215
248
|
const home = opts.private ? "private" : existing ?? (parsed.data_class !== "public" || this.store.unified ? "private" : "public");
|
|
216
|
-
const dir = this.
|
|
217
|
-
|
|
218
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
249
|
+
const dir = this.ensureDir(home, "policies");
|
|
250
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
219
251
|
return parsed;
|
|
220
252
|
}
|
|
221
253
|
/** Publish a new policy lifecycle record without overwriting a concurrent
|
|
@@ -238,9 +270,8 @@ export class PolicyRepository {
|
|
|
238
270
|
return { policy: existing, created: false };
|
|
239
271
|
if (otherHome)
|
|
240
272
|
throw new Error(`policy ${parsed.id} already exists in the ${home === "public" ? "private" : "public"} home`);
|
|
241
|
-
const dir = this.
|
|
242
|
-
|
|
243
|
-
if (writeFileAtomicIfAbsent(join(dir, `${parsed.id}.json`), encode(parsed))) {
|
|
273
|
+
const dir = this.ensureDir(home, "policies");
|
|
274
|
+
if (writeArtifactIfAbsent(dir, `${parsed.id}.json`, encode(parsed))) {
|
|
244
275
|
const racedOtherHome = this.getPolicy(parsed.id, home === "public" ? { privateOnly: true } : { publicOnly: true });
|
|
245
276
|
if (racedOtherHome)
|
|
246
277
|
throw new Error(`policy ${parsed.id} was published concurrently in both public and private homes`);
|
|
@@ -277,9 +308,8 @@ export class PolicyRepository {
|
|
|
277
308
|
assertCompositionBinding(policy, composition, plan.composition);
|
|
278
309
|
}
|
|
279
310
|
}
|
|
280
|
-
const dir = this.
|
|
281
|
-
|
|
282
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
311
|
+
const dir = this.ensureDir(home, "proofs");
|
|
312
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
283
313
|
return parsed;
|
|
284
314
|
}
|
|
285
315
|
/** Publish an immutable proof without replacing a concurrent writer. */
|
|
@@ -315,9 +345,8 @@ export class PolicyRepository {
|
|
|
315
345
|
throw new Error(`proof ${parsed.id} already exists with different immutable content`);
|
|
316
346
|
return { proof: existing, created: false };
|
|
317
347
|
}
|
|
318
|
-
const dir = this.
|
|
319
|
-
|
|
320
|
-
if (writeFileAtomicIfAbsent(join(dir, `${parsed.id}.json`), encode(parsed)))
|
|
348
|
+
const dir = this.ensureDir(home, "proofs");
|
|
349
|
+
if (writeArtifactIfAbsent(dir, `${parsed.id}.json`, encode(parsed)))
|
|
321
350
|
return { proof: parsed, created: true };
|
|
322
351
|
const winner = this.getProof(parsed.id, homeOpts);
|
|
323
352
|
if (!winner)
|
|
@@ -346,9 +375,8 @@ export class PolicyRepository {
|
|
|
346
375
|
if (parsed.policy_candidate_hash !== policyProofHash(policy, composition))
|
|
347
376
|
throw new Error(`composite plan ${parsed.id} policy hash mismatch`);
|
|
348
377
|
}
|
|
349
|
-
const dir = this.
|
|
350
|
-
|
|
351
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
378
|
+
const dir = this.ensureDir(home, "plans");
|
|
379
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
352
380
|
return parsed;
|
|
353
381
|
}
|
|
354
382
|
/** Publish an immutable proof plan without replacing a concurrent writer. */
|
|
@@ -378,9 +406,8 @@ export class PolicyRepository {
|
|
|
378
406
|
throw new Error(`proof plan ${parsed.id} already exists with different immutable content`);
|
|
379
407
|
return { plan: existing, created: false };
|
|
380
408
|
}
|
|
381
|
-
const dir = this.
|
|
382
|
-
|
|
383
|
-
if (writeFileAtomicIfAbsent(join(dir, `${parsed.id}.json`), encode(parsed)))
|
|
409
|
+
const dir = this.ensureDir(home, "plans");
|
|
410
|
+
if (writeArtifactIfAbsent(dir, `${parsed.id}.json`, encode(parsed)))
|
|
384
411
|
return { plan: parsed, created: true };
|
|
385
412
|
const winner = this.getPlan(parsed.id, homeOpts);
|
|
386
413
|
if (!winner)
|
|
@@ -400,9 +427,8 @@ export class PolicyRepository {
|
|
|
400
427
|
if (!policy || parsed.data_class !== policy.data_class || parsed.policy_hash !== policySemanticHash(policy)) {
|
|
401
428
|
throw new Error(`corpus ${parsed.id} does not match policy ${policyId} semantics/data class`);
|
|
402
429
|
}
|
|
403
|
-
const dir = this.
|
|
404
|
-
|
|
405
|
-
writeFileAtomic(join(dir, `${policyId}.json`), encode(parsed));
|
|
430
|
+
const dir = this.ensureDir(home, "corpora");
|
|
431
|
+
writeArtifact(dir, `${policyId}.json`, encode(parsed));
|
|
406
432
|
return parsed;
|
|
407
433
|
}
|
|
408
434
|
putEvidence(event, opts = {}) {
|
|
@@ -413,9 +439,8 @@ export class PolicyRepository {
|
|
|
413
439
|
if (home === "public" && parsed.data_class !== "public") {
|
|
414
440
|
throw new Error(`refusing to write ${parsed.data_class} evidence ${parsed.id} into the public home`);
|
|
415
441
|
}
|
|
416
|
-
const dir = this.
|
|
417
|
-
|
|
418
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
442
|
+
const dir = this.ensureDir(home, "evidence");
|
|
443
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
419
444
|
return parsed;
|
|
420
445
|
}
|
|
421
446
|
putDisposition(disposition, policyId) {
|
|
@@ -451,9 +476,8 @@ export class PolicyRepository {
|
|
|
451
476
|
if (!current && parsed.supersedes)
|
|
452
477
|
throw new Error(`history disposition ${parsed.id} supersedes no current disposition for this proof hit`);
|
|
453
478
|
currentHistoryDispositions([...records, parsed]);
|
|
454
|
-
const dir = this.
|
|
455
|
-
|
|
456
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
479
|
+
const dir = this.ensureDir(home, "dispositions");
|
|
480
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
457
481
|
return parsed;
|
|
458
482
|
}
|
|
459
483
|
putShadowEvaluation(evaluation, policyId) {
|
|
@@ -483,9 +507,8 @@ export class PolicyRepository {
|
|
|
483
507
|
const existing = this.listShadowEvaluations(homeOpts).find((record) => shadowEvaluationIdentityHash(record) === shadowEvaluationIdentityHash(parsed));
|
|
484
508
|
if (existing)
|
|
485
509
|
return existing;
|
|
486
|
-
const dir = this.
|
|
487
|
-
|
|
488
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
510
|
+
const dir = this.ensureDir(home, "shadow");
|
|
511
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
489
512
|
return parsed;
|
|
490
513
|
}
|
|
491
514
|
putShadowDisposition(disposition, policyId) {
|
|
@@ -521,9 +544,8 @@ export class PolicyRepository {
|
|
|
521
544
|
if (!current && parsed.supersedes)
|
|
522
545
|
throw new Error(`shadow disposition ${parsed.id} supersedes no current disposition for this evaluation`);
|
|
523
546
|
currentShadowDispositions([...records, parsed]);
|
|
524
|
-
const dir = this.
|
|
525
|
-
|
|
526
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
547
|
+
const dir = this.ensureDir(home, "shadow");
|
|
548
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
527
549
|
return parsed;
|
|
528
550
|
}
|
|
529
551
|
}
|
|
@@ -653,14 +675,18 @@ export function movePolicyArtifactsToPrivate(publicHunchDir, privateHunchDir) {
|
|
|
653
675
|
}
|
|
654
676
|
}
|
|
655
677
|
for (const { kind, from, to, pub, priv, keyFor } of staged) {
|
|
678
|
+
storeArtifactPath(dirname(from), basename(from));
|
|
656
679
|
if (!pub.length && !existsSync(from))
|
|
657
680
|
continue;
|
|
681
|
+
storeArtifactPath(dirname(to), basename(to));
|
|
658
682
|
mkdirSync(to, { recursive: true });
|
|
683
|
+
storeArtifactPath(dirname(to), basename(to));
|
|
659
684
|
for (const rec of pub) {
|
|
660
685
|
const key = keyFor(rec);
|
|
661
686
|
if (!priv.has(key))
|
|
662
|
-
|
|
687
|
+
writeArtifact(to, `${key}.json`, encode(rec));
|
|
663
688
|
}
|
|
689
|
+
storeArtifactPath(dirname(from), basename(from));
|
|
664
690
|
rmSync(from, { recursive: true, force: true });
|
|
665
691
|
counts[kind] = pub.length;
|
|
666
692
|
}
|
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,28 @@ 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
|
-
|
|
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
|
+
}
|
|
63
|
+
function normalizeToolInput(value, allowPatch = false) {
|
|
53
64
|
const raw = obj(value);
|
|
54
65
|
if (!raw)
|
|
55
66
|
return undefined;
|
|
67
|
+
// Only Codex's apply_patch tool carries patch text in a generic `input`,
|
|
68
|
+
// `patch`, or `content` field. A normal Write can contain documentation or
|
|
69
|
+
// examples with these markers; interpreting those as a patch would retarget
|
|
70
|
+
// policy to the first file named in the prose.
|
|
71
|
+
const patched = allowPatch ? applyPatchInput(raw) : undefined;
|
|
72
|
+
if (patched)
|
|
73
|
+
return patched;
|
|
56
74
|
const replacementChunks = Array.isArray(raw.ReplacementChunks) ? raw.ReplacementChunks : raw.replacementChunks;
|
|
57
75
|
const chunkEdits = Array.isArray(replacementChunks)
|
|
58
76
|
? replacementChunks.map((chunk) => obj(chunk)).filter((chunk) => !!chunk)
|
|
@@ -63,7 +81,9 @@ function normalizeToolInput(value) {
|
|
|
63
81
|
new_string: stringAt(raw, "new_string", "newString", "ReplacementContent", "replacementContent", "TargetContent", "targetContent"),
|
|
64
82
|
content: stringAt(raw, "content", "contents", "CodeContent", "codeContent"),
|
|
65
83
|
edits: edits(raw.edits) ?? edits(raw.files) ?? chunkEdits,
|
|
66
|
-
|
|
84
|
+
// Codex's shell tools carry argv arrays (["bash", "-lc", "…"]); policies read one string.
|
|
85
|
+
command: stringAt(raw, "command", "commandLine", "CommandLine", "cmd")
|
|
86
|
+
?? (Array.isArray(raw.command) && raw.command.every(p => typeof p === "string") ? raw.command.join(" ") : undefined),
|
|
67
87
|
skill: stringAt(raw, "skill", "skillName", "name"),
|
|
68
88
|
};
|
|
69
89
|
return Object.values(out).some((v) => v !== undefined) ? out : undefined;
|
|
@@ -189,13 +209,17 @@ export function normalizeHookEvent(raw, provider) {
|
|
|
189
209
|
const event = eventName(input.hook_event_name ?? input.hookEventName ?? input.event, provider);
|
|
190
210
|
if (!event)
|
|
191
211
|
return null;
|
|
192
|
-
const
|
|
212
|
+
const rawToolName = stringAt(input, "tool_name", "toolName");
|
|
213
|
+
const toolInput = normalizeToolInput(input.tool_input ?? input.toolInput, provider === "codex" && /^(?:apply_patch|patch)$/i.test(rawToolName ?? ""));
|
|
193
214
|
const toolOutcome = normalizeToolOutcome(input, event);
|
|
194
215
|
return {
|
|
195
216
|
hook_event_name: event,
|
|
196
217
|
session_id: stringAt(input, "session_id", "sessionId", "conversation_id", "conversationId"),
|
|
197
|
-
|
|
198
|
-
|
|
218
|
+
// Codex delivers the same lifecycle payload with `turn_id` where Claude Code
|
|
219
|
+
// says `prompt_id`; both are native per-prompt identities, never synthesized.
|
|
220
|
+
...(provider === "codex" && input.prompt_id === undefined && input.turn_id !== undefined ? { prompt_id: typeof input.turn_id === "string" ? input.turn_id : "" } : {}),
|
|
221
|
+
...(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] : ""])) : {}),
|
|
222
|
+
tool_name: hunchToolName(rawToolName, toolInput ?? {}),
|
|
199
223
|
tool_input: toolInput,
|
|
200
224
|
...(toolOutcome ? { tool_outcome: toolOutcome } : {}),
|
|
201
225
|
prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
|
package/dist/core/checkreport.js
CHANGED
|
@@ -200,7 +200,7 @@ export function renderMarkdown(r) {
|
|
|
200
200
|
r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
|
|
201
201
|
r.vetoBlocking ? `${r.vetoBlocking} reversed-decision veto(es)` : "",
|
|
202
202
|
].filter(Boolean).join(" + ");
|
|
203
|
-
out.push(`❌ **
|
|
203
|
+
out.push(`❌ **Merge requires review: ${reasons}.** Check the cited evidence and verify that the recorded requirements still hold before merging.`);
|
|
204
204
|
}
|
|
205
205
|
else if (r.strict) {
|
|
206
206
|
out.push(`ℹ️ Nothing here is a direct, high-confidence, non-stale blocking invariant — **not blocking** this PR.`);
|
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();
|
package/dist/core/events.js
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* assert — those are conformance-only predicates checked by a different gate
|
|
17
17
|
* (`hunch conform`), not the edit hook. This schema records only what each gate
|
|
18
18
|
* actually knows; it never fabricates the conformance shape for a plain block. */
|
|
19
|
-
import { appendFileSync,
|
|
19
|
+
import { appendFileSync, closeSync, constants, fstatSync, lstatSync, openSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
|
+
import { readStoreArtifact, storeArtifactPath } from "./storeArtifact.js";
|
|
21
22
|
export function eventsLogPath(paths) {
|
|
22
23
|
return join(paths.hunch, "events.log");
|
|
23
24
|
}
|
|
@@ -25,19 +26,34 @@ export function eventsLogPath(paths) {
|
|
|
25
26
|
* call site is the edit hook, which MUST NEVER break an agent on failure
|
|
26
27
|
* (con_03a0b94b2e). A dropped catch-log line is an acceptable loss. */
|
|
27
28
|
export function appendEvent(paths, event) {
|
|
29
|
+
let fd;
|
|
28
30
|
try {
|
|
29
|
-
|
|
31
|
+
const file = storeArtifactPath(paths.hunch, "events.log");
|
|
32
|
+
fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW);
|
|
33
|
+
const opened = fstatSync(fd);
|
|
34
|
+
const current = lstatSync(storeArtifactPath(paths.hunch, "events.log"));
|
|
35
|
+
if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== current.dev || opened.ino !== current.ino)
|
|
36
|
+
return;
|
|
37
|
+
appendFileSync(fd, `${JSON.stringify(event)}\n`);
|
|
30
38
|
}
|
|
31
39
|
catch {
|
|
32
40
|
/* best effort — a lost audit line must never surface to the agent */
|
|
33
41
|
}
|
|
42
|
+
finally {
|
|
43
|
+
if (fd !== undefined) {
|
|
44
|
+
try {
|
|
45
|
+
closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
catch { /* logging remains best effort on close failure too */ }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
34
50
|
}
|
|
35
51
|
/** Read + parse the catch-log. Malformed lines are skipped, not fatal (the log is
|
|
36
52
|
* derived; one bad line never poisons the aggregation). Missing log → []. */
|
|
37
53
|
export function readEvents(paths) {
|
|
38
54
|
let raw;
|
|
39
55
|
try {
|
|
40
|
-
raw =
|
|
56
|
+
raw = readStoreArtifact(paths.hunch, ["events.log"]) ?? "";
|
|
41
57
|
}
|
|
42
58
|
catch {
|
|
43
59
|
return []; // no catches recorded yet
|