@davesheffer/hunch 1.32.4 → 1.32.7

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 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
- 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.
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, native `Bash`/`PowerShell` post-tool observation, Stop cards); project-layer hooks load only for a trusted project and must be trusted once in Codex with `/hooks`. Failure capture is certified only by an explicit failed-tool lifecycle event; a successful `PostToolUse` observation does not prove it. 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
 
@@ -288,4 +288,4 @@ See the [changelog](CHANGELOG.md) for release detail and the [roadmap](ROADMAP.m
288
288
  - [Architecture benchmark](bench/architectural-conformance.md)
289
289
  - [Contributing](CONTRIBUTING.md)
290
290
 
291
- Apache-2.0
291
+ Apache-2.0
package/dist/cli/index.js CHANGED
@@ -4470,7 +4470,7 @@ program
4470
4470
  const root = findRoot();
4471
4471
  // The host delivered this event: runtime evidence for `hunch integrations check`,
4472
4472
  // recorded before any policy decision so firmness never hides delivery itself.
4473
- recordHookObservation(root, provider, evt.hook_event_name);
4473
+ recordHookObservation(root, provider, evt.hook_event_name, evt.tool_outcome?.status);
4474
4474
  const paths = hunchPaths(root);
4475
4475
  const firmness = readConfig(paths).firmness;
4476
4476
  if (firmness === "off")
@@ -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 bootstrap --behavior-deps <candidate>) or evaluate where they were built");
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
  }
@@ -9,6 +9,7 @@ export declare class PolicyRepository {
9
9
  private readonly privateHome?;
10
10
  constructor(root: string, store: HunchStore);
11
11
  private dir;
12
+ private ensureDir;
12
13
  private policiesIn;
13
14
  private proofsIn;
14
15
  private evidenceIn;
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
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
- if (!existsSync(dir))
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(dir).filter((n) => n.endsWith(".json")).sort()) {
41
+ for (const name of readdirSync(safeDir).filter((n) => n.endsWith(".json")).sort()) {
17
42
  try {
18
- out.push(parse(JSON.parse(readFileSync(join(dir, name), "utf8"))));
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 join(base, kind);
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(join(this.dir("private", "policies"), `${id}.json`)))
236
+ if (this.privateHome && existsSync(artifactFile(this.dir("private", "policies"), `${id}.json`)))
204
237
  return "private";
205
- if (existsSync(join(this.dir("public", "policies"), `${id}.json`)))
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.dir(home, "policies");
217
- mkdirSync(dir, { recursive: true });
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.dir(home, "policies");
242
- mkdirSync(dir, { recursive: true });
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.dir(home, "proofs");
281
- mkdirSync(dir, { recursive: true });
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.dir(home, "proofs");
319
- mkdirSync(dir, { recursive: true });
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.dir(home, "plans");
350
- mkdirSync(dir, { recursive: true });
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.dir(home, "plans");
382
- mkdirSync(dir, { recursive: true });
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.dir(home, "corpora");
404
- mkdirSync(dir, { recursive: true });
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.dir(home, "evidence");
417
- mkdirSync(dir, { recursive: true });
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.dir(home, "dispositions");
455
- mkdirSync(dir, { recursive: true });
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.dir(home, "shadow");
487
- mkdirSync(dir, { recursive: true });
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.dir(home, "shadow");
525
- mkdirSync(dir, { recursive: true });
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
- writeFileAtomic(join(to, `${key}.json`), encode(rec));
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
  }
@@ -60,11 +60,15 @@ function applyPatchInput(raw) {
60
60
  const file = PATCH_FILE.exec(patch)?.[1];
61
61
  return file ? { file_path: file, content: patch } : undefined;
62
62
  }
63
- function normalizeToolInput(value) {
63
+ function normalizeToolInput(value, allowPatch = false) {
64
64
  const raw = obj(value);
65
65
  if (!raw)
66
66
  return undefined;
67
- const patched = applyPatchInput(raw);
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;
68
72
  if (patched)
69
73
  return patched;
70
74
  const replacementChunks = Array.isArray(raw.ReplacementChunks) ? raw.ReplacementChunks : raw.replacementChunks;
@@ -103,6 +107,38 @@ function toolOutput(value) {
103
107
  return "";
104
108
  }
105
109
  }
110
+ function explicitToolOutcome(response) {
111
+ const raw = obj(response);
112
+ if (!raw)
113
+ return typeof response === "string" && response.trim() ? "success" : "unknown";
114
+ if (raw.success === false || raw.is_error === true || raw.isError === true || (raw.error !== undefined && raw.error !== null))
115
+ return "failure";
116
+ const status = raw.status;
117
+ if (typeof status === "string") {
118
+ if (/^(?:failure|failed|error|errored)$/i.test(status.trim()))
119
+ return "failure";
120
+ }
121
+ let explicitSuccess = raw.success === true || raw.is_error === false || raw.isError === false;
122
+ for (const key of ["exit_code", "exitCode", "return_code", "returnCode"]) {
123
+ const value = raw[key];
124
+ const numeric = typeof value === "number" ? value : typeof value === "string" && /^-?\d+$/.test(value.trim()) ? Number(value) : undefined;
125
+ if (numeric === undefined || !Number.isFinite(numeric))
126
+ continue;
127
+ if (numeric !== 0)
128
+ return "failure";
129
+ explicitSuccess = true;
130
+ }
131
+ if (typeof status === "string" && /^(?:success|succeeded|ok|completed)$/i.test(status.trim()))
132
+ explicitSuccess = true;
133
+ if (explicitSuccess)
134
+ return "success";
135
+ // Common successful tool-result shapes carry output fields even when the
136
+ // output is empty. An unstructured empty string (Codex's native failure
137
+ // payload) remains unknown until the host supplies an explicit status.
138
+ if (["stdout", "stderr", "output", "content"].some(key => Object.prototype.hasOwnProperty.call(raw, key)))
139
+ return "success";
140
+ return "unknown";
141
+ }
106
142
  function normalizeToolOutcome(input, event) {
107
143
  if (event !== "PostToolUse" && event !== "PostToolUseFailure")
108
144
  return undefined;
@@ -110,11 +146,7 @@ function normalizeToolOutcome(input, event) {
110
146
  return {
111
147
  // Claude Code splits successful and failed calls into separate lifecycle
112
148
  // events. Providers without that split may expose an explicit result flag.
113
- status: event === "PostToolUseFailure"
114
- ? "failure"
115
- : obj(response)?.success === false || obj(response)?.is_error === true || obj(response)?.isError === true
116
- ? "failure"
117
- : "success",
149
+ status: event === "PostToolUseFailure" ? "failure" : explicitToolOutcome(response),
118
150
  output: event === "PostToolUseFailure"
119
151
  ? toolOutput(input.error ?? response)
120
152
  : toolOutput(response),
@@ -205,7 +237,8 @@ export function normalizeHookEvent(raw, provider) {
205
237
  const event = eventName(input.hook_event_name ?? input.hookEventName ?? input.event, provider);
206
238
  if (!event)
207
239
  return null;
208
- const toolInput = normalizeToolInput(input.tool_input ?? input.toolInput);
240
+ const rawToolName = stringAt(input, "tool_name", "toolName");
241
+ const toolInput = normalizeToolInput(input.tool_input ?? input.toolInput, provider === "codex" && /^(?:apply_patch|patch)$/i.test(rawToolName ?? ""));
209
242
  const toolOutcome = normalizeToolOutcome(input, event);
210
243
  return {
211
244
  hook_event_name: event,
@@ -214,7 +247,7 @@ export function normalizeHookEvent(raw, provider) {
214
247
  // says `prompt_id`; both are native per-prompt identities, never synthesized.
215
248
  ...(provider === "codex" && input.prompt_id === undefined && input.turn_id !== undefined ? { prompt_id: typeof input.turn_id === "string" ? input.turn_id : "" } : {}),
216
249
  ...(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] : ""])) : {}),
217
- tool_name: hunchToolName(stringAt(input, "tool_name", "toolName"), toolInput ?? {}),
250
+ tool_name: hunchToolName(rawToolName, toolInput ?? {}),
218
251
  tool_input: toolInput,
219
252
  ...(toolOutcome ? { tool_outcome: toolOutcome } : {}),
220
253
  prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
@@ -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(`❌ **This PR breaks ${reasons}.** Resolve or supersede the decision before merge.`);
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.`);
@@ -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, readFileSync } from "node:fs";
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
- appendFileSync(eventsLogPath(paths), `${JSON.stringify(event)}\n`);
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 = readFileSync(eventsLogPath(paths), "utf8");
56
+ raw = readStoreArtifact(paths.hunch, ["events.log"]) ?? "";
41
57
  }
42
58
  catch {
43
59
  return []; // no catches recorded yet
@@ -1,9 +1,12 @@
1
+ export type HookOutcomeEvidence = "success" | "failure" | "unknown";
1
2
  export interface HookObservation {
2
3
  provider: string;
3
4
  event: string;
4
5
  at: string;
5
6
  version: string;
7
+ /** Present for observations recorded after outcome tracking was added. */
8
+ outcome?: HookOutcomeEvidence | null;
6
9
  }
7
10
  /** Never throws (con_03a0b94b2e): a missing ledger costs evidence, not the edit. */
8
- export declare function recordHookObservation(root: string, provider: string, event: string): void;
11
+ export declare function recordHookObservation(root: string, provider: string, event: string, outcome?: HookOutcomeEvidence): void;
9
12
  export declare function readHookObservations(root: string): HookObservation[];
@@ -9,15 +9,28 @@ import { HUNCH_VERSION } from "./version.js";
9
9
  function ensureTable(db) {
10
10
  db.exec(`CREATE TABLE IF NOT EXISTS hook_observations (
11
11
  provider TEXT NOT NULL, event TEXT NOT NULL, at TEXT NOT NULL, version TEXT NOT NULL,
12
+ outcome TEXT,
12
13
  PRIMARY KEY (provider, event)
13
14
  )`);
15
+ // Existing machine ledgers predate outcome tracking. Their rows remain
16
+ // intentionally unknown until a later hook delivery supplies a result.
17
+ const columns = db.prepare("PRAGMA table_info(hook_observations)").all();
18
+ if (!columns.some(column => column.name === "outcome"))
19
+ db.exec("ALTER TABLE hook_observations ADD COLUMN outcome TEXT");
14
20
  }
15
21
  /** Never throws (con_03a0b94b2e): a missing ledger costs evidence, not the edit. */
16
- export function recordHookObservation(root, provider, event) {
22
+ export function recordHookObservation(root, provider, event, outcome) {
17
23
  try {
18
24
  withServedDatabase(root, db => {
19
25
  ensureTable(db);
20
- db.prepare("INSERT OR REPLACE INTO hook_observations VALUES (?, ?, ?, ?)").run(provider, event, new Date().toISOString(), HUNCH_VERSION);
26
+ const evidence = outcome === "failure" || outcome === "success" ? outcome : null;
27
+ const at = new Date().toISOString();
28
+ db.prepare(`INSERT INTO hook_observations (provider, event, at, version, outcome)
29
+ VALUES (?, ?, ?, ?, ?)
30
+ ON CONFLICT(provider, event) DO UPDATE SET
31
+ at = CASE WHEN excluded.outcome = 'failure' OR hook_observations.outcome IS NULL OR hook_observations.outcome != 'failure' THEN excluded.at ELSE hook_observations.at END,
32
+ version = CASE WHEN excluded.outcome = 'failure' OR hook_observations.outcome IS NULL OR hook_observations.outcome != 'failure' THEN excluded.version ELSE hook_observations.version END,
33
+ outcome = CASE WHEN excluded.outcome = 'failure' OR hook_observations.outcome = 'failure' THEN 'failure' ELSE excluded.outcome END`).run(provider, event, at, HUNCH_VERSION, evidence);
21
34
  });
22
35
  }
23
36
  catch { /* evidence is optional; the hook response is not */ }
@@ -27,7 +40,7 @@ export function readHookObservations(root) {
27
40
  return [];
28
41
  return withServedDatabase(root, db => {
29
42
  ensureTable(db);
30
- return db.prepare("SELECT provider, event, at, version FROM hook_observations ORDER BY at DESC").all();
43
+ return db.prepare("SELECT provider, event, at, version, outcome FROM hook_observations ORDER BY at DESC").all();
31
44
  });
32
45
  }
33
46
  //# sourceMappingURL=hookObservations.js.map
package/dist/core/io.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Durable file writes for the Hunch. */
2
- import { closeSync, fsyncSync, linkSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
2
+ import { closeSync, fchmodSync, fsyncSync, linkSync, lstatSync, openSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  let counter = 0;
5
5
  const renameRetryDelaysMs = [10, 20, 40, 80];
@@ -24,20 +24,17 @@ const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_
24
24
  */
25
25
  export function writeFileAtomic(file, data) {
26
26
  const tmp = `${file}.tmp${process.pid}.${counter++}`;
27
+ let mode;
27
28
  try {
28
- const fd = openSync(tmp, "w");
29
- try {
30
- writeSync(fd, data);
31
- fsyncSync(fd); // data blocks reach disk before the rename's metadata can
32
- }
33
- finally {
34
- closeSync(fd);
35
- }
29
+ const existing = lstatSync(file);
30
+ if (existing.isFile())
31
+ mode = existing.mode & 0o777;
36
32
  }
37
- catch (e) {
38
- cleanupTmp(tmp);
39
- throw e;
33
+ catch (error) {
34
+ if (error.code !== "ENOENT")
35
+ throw error;
40
36
  }
37
+ writeFileAtomicTmp(tmp, data, mode);
41
38
  try {
42
39
  renameWithContentionRetry(tmp, file);
43
40
  }
@@ -85,8 +82,10 @@ function isRenameContention(error) {
85
82
  * semantics, so concurrent lifecycle writers can never be overwritten. */
86
83
  export function writeFileAtomicIfAbsent(file, data) {
87
84
  const tmp = `${file}.tmp${process.pid}.${counter++}`;
85
+ // An occupied temp path is an error, not evidence that the target exists.
86
+ // Only enter the publication/cleanup block once we own the temporary file.
87
+ writeFileAtomicTmp(tmp, data);
88
88
  try {
89
- writeFileAtomicTmp(tmp, data);
90
89
  linkSync(tmp, file);
91
90
  return true;
92
91
  }
@@ -104,14 +103,26 @@ export function writeFileAtomicIfAbsent(file, data) {
104
103
  }
105
104
  }
106
105
  /** Write + fsync a fresh temp file (shared by both atomic writers). */
107
- function writeFileAtomicTmp(tmp, data) {
108
- const fd = openSync(tmp, "w");
106
+ function writeFileAtomicTmp(tmp, data, mode) {
107
+ // Exclusive creation rejects stale files and links without truncating their
108
+ // contents. If open fails, the path belongs to somebody else: never unlink it.
109
+ const fd = openSync(tmp, "wx", mode ?? 0o666);
109
110
  try {
110
- writeSync(fd, data);
111
- fsyncSync(fd);
111
+ try {
112
+ writeFileSync(fd, data);
113
+ // The replacement inode must retain an existing file's permissions,
114
+ // including private config files that contain credentials.
115
+ if (mode !== undefined)
116
+ fchmodSync(fd, mode);
117
+ fsyncSync(fd);
118
+ }
119
+ finally {
120
+ closeSync(fd);
121
+ }
112
122
  }
113
- finally {
114
- closeSync(fd);
123
+ catch (error) {
124
+ cleanupTmp(tmp);
125
+ throw error;
115
126
  }
116
127
  }
117
128
  /** Remove a temp file, riding out a transient external hold (AV/indexer) with one
@@ -24,7 +24,7 @@ export function parseJsonc(raw) {
24
24
  continue;
25
25
  }
26
26
  if (char === "/" && next === "/") {
27
- while (index < raw.length && raw[index] !== "\n")
27
+ while (index < raw.length && raw[index] !== "\n" && raw[index] !== "\r")
28
28
  index += 1;
29
29
  withoutComments += "\n";
30
30
  continue;
@@ -33,7 +33,12 @@ export function parseJsonc(raw) {
33
33
  index += 2;
34
34
  while (index < raw.length && !(raw[index] === "*" && raw[index + 1] === "/"))
35
35
  index += 1;
36
+ if (index >= raw.length)
37
+ throw new SyntaxError("unterminated JSONC block comment");
36
38
  index += 1;
39
+ // A comment separates tokens. Removing it outright would silently turn
40
+ // invalid input such as 1/* comment */2 into the valid number 12.
41
+ withoutComments += " ";
37
42
  continue;
38
43
  }
39
44
  withoutComments += char;
@@ -62,8 +67,13 @@ export function parseJsonc(raw) {
62
67
  let cursor = index + 1;
63
68
  while (cursor < withoutComments.length && /\s/.test(withoutComments[cursor]))
64
69
  cursor += 1;
65
- if (withoutComments[cursor] === "}" || withoutComments[cursor] === "]")
66
- continue;
70
+ if (withoutComments[cursor] === "}" || withoutComments[cursor] === "]") {
71
+ const previous = normalized.trimEnd().at(-1);
72
+ // A trailing comma must follow a value, never an empty collection or
73
+ // another comma. Keep malformed input intact for JSON.parse to reject.
74
+ if (previous && !"[{,:".includes(previous))
75
+ continue;
76
+ }
67
77
  }
68
78
  normalized += char;
69
79
  }
@@ -0,0 +1,7 @@
1
+ /** Containment for store artifacts outside JsonStore's entity registry (ledgers,
2
+ * policy proofs, and local audit logs). The explicitly selected store's parent
3
+ * may have a platform alias, but no component inside that store may be a link. */
4
+ export declare function storeArtifactPath(hunchDir: string, ...parts: string[]): string;
5
+ /** A missing artifact is distinct from an unsafe/unreadable artifact. Reuse the
6
+ * scanner's bounded descriptor read; policy and ledger corruption must fail visibly. */
7
+ export declare function readStoreArtifact(hunchDir: string, parts: string[], maxBytes?: number): string | null;