@davesheffer/hunch 1.38.1 → 1.39.1

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.
Files changed (54) hide show
  1. package/dist/cli/index.js +355 -55
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/cli/serve.js +1 -0
  4. package/dist/client/readOrCompute.d.ts +77 -0
  5. package/dist/client/readOrCompute.js +85 -0
  6. package/dist/client/state.d.ts +1 -0
  7. package/dist/client/state.js +1 -0
  8. package/dist/constitution/g2.d.ts +1 -0
  9. package/dist/constitution/service.js +8 -0
  10. package/dist/constitution/sourceMutation.js +23 -18
  11. package/dist/core/agenthook.d.ts +14 -0
  12. package/dist/core/agenthook.js +48 -5
  13. package/dist/core/changeProof.js +5 -1
  14. package/dist/core/checkreport.d.ts +7 -0
  15. package/dist/core/checkreport.js +20 -3
  16. package/dist/core/compare.js +3 -2
  17. package/dist/core/config.d.ts +16 -0
  18. package/dist/core/config.js +13 -0
  19. package/dist/core/machine.d.ts +20 -0
  20. package/dist/core/machine.js +101 -0
  21. package/dist/core/taskReportEvidence.js +6 -6
  22. package/dist/core/types.d.ts +67 -1
  23. package/dist/core/types.js +3 -0
  24. package/dist/core/workspace.d.ts +256 -0
  25. package/dist/core/workspace.js +359 -0
  26. package/dist/extractors/diff.d.ts +34 -0
  27. package/dist/extractors/diff.js +147 -5
  28. package/dist/extractors/git.d.ts +40 -11
  29. package/dist/extractors/git.js +147 -43
  30. package/dist/extractors/helm.d.ts +17 -28
  31. package/dist/extractors/helm.js +12 -12
  32. package/dist/extractors/indexer.js +171 -7
  33. package/dist/extractors/k8sManifest.d.ts +59 -0
  34. package/dist/extractors/k8sManifest.js +507 -0
  35. package/dist/extractors/workspaces.d.ts +28 -0
  36. package/dist/extractors/workspaces.js +427 -0
  37. package/dist/integrations/claudemd.js +1 -0
  38. package/dist/integrations/gitignore.d.ts +27 -2
  39. package/dist/integrations/gitignore.js +103 -17
  40. package/dist/integrations/hooks.d.ts +63 -7
  41. package/dist/integrations/hooks.js +350 -38
  42. package/dist/integrations/scaffold.js +11 -0
  43. package/dist/integrations/workspaceLedger.d.ts +93 -0
  44. package/dist/integrations/workspaceLedger.js +307 -0
  45. package/dist/mcp/server.js +59 -5
  46. package/dist/serve/app.d.ts +2 -0
  47. package/dist/serve/app.js +107 -92
  48. package/dist/serve/mcpHttp.d.ts +27 -0
  49. package/dist/serve/mcpHttp.js +95 -0
  50. package/dist/store/hunchStore.d.ts +4 -2
  51. package/dist/store/hunchStore.js +23 -6
  52. package/dist/store/stateBinding.js +83 -35
  53. package/package.json +1 -1
  54. package/server.json +2 -2
@@ -0,0 +1,77 @@
1
+ /**
2
+ * readOrCompute — the reuse rule every derived-state writer otherwise re-derives by hand, and
3
+ * gets wrong first. No dependencies beyond the platform: canonical JSON and WebCrypto SHA-256.
4
+ *
5
+ * Rules applied, in order (docs/nuryel-state-contract.md, "Read or compute"):
6
+ * 1. Read the subject. A current statement under the same transform whose dependency SET equals
7
+ * the given one is reused and nothing is computed. The set, not the order: the server derives
8
+ * a statement's identity from its dependency hashes sorted, so order never makes it new.
9
+ * 2. Otherwise run `compute` once and write the result as the subject's current statement.
10
+ * 3. The idempotency key names the REQUEST: subject, transform and dependencies, the content
11
+ * hash, and computed_at. A key without the content hash is reused when the same evidence
12
+ * yields new wording, and the contract refuses a reused key with another payload for good
13
+ * (the pilot's stuck outbox).
14
+ * 4. `supersedes` names the current statement it replaces under the same transform; the server
15
+ * keeps one current statement per subject and transform and refuses a second.
16
+ * 5. The audience carries forward: without an explicit `visibility` the new statement keeps the
17
+ * one it supersedes (audiences are preserved across supersession). An explicit change sends
18
+ * the predecessor's record hash as `expected_version`, which the server requires.
19
+ * 6. No retries. A refusal or a transport failure surfaces to the caller. Calling again re-reads
20
+ * first, so a write that did land is reused instead of written twice.
21
+ */
22
+ import type { DependencyRef, DerivedState, ReadResponse, RecordsResponse, Scope, WriteResult } from "../core/stateContract.js";
23
+ /** What the helper needs from a client; `createStateClient` satisfies it. */
24
+ export interface ReadOrComputeClient {
25
+ read(request: {
26
+ scope: Scope;
27
+ subject: string;
28
+ facets: ["derived"];
29
+ }): Promise<ReadResponse>;
30
+ records(request: {
31
+ scope: Scope;
32
+ ids: string[];
33
+ }): Promise<RecordsResponse>;
34
+ write(request: {
35
+ scope: Scope;
36
+ facet: "derived";
37
+ record: Record<string, unknown>;
38
+ idempotency_key: string;
39
+ supersedes?: string;
40
+ expected_version?: string;
41
+ }): Promise<WriteResult>;
42
+ }
43
+ export interface ComputedContent {
44
+ content: string;
45
+ field_provenance?: DerivedState["field_provenance"];
46
+ }
47
+ export interface ReadOrComputeRequest {
48
+ scope: Scope;
49
+ subject: string;
50
+ transform_version: string;
51
+ /** What the statement rests on; at least one. Equal sets reuse, whatever their order. */
52
+ dependencies: DependencyRef[];
53
+ provenance: DerivedState["provenance"];
54
+ /** Record audience. Omitted: the superseded statement's audience is kept. */
55
+ visibility?: DerivedState["visibility"];
56
+ /** Runs only when no current statement rests on exactly these dependencies. */
57
+ compute: () => string | ComputedContent | Promise<string | ComputedContent>;
58
+ /** ISO timestamp for computed_at; defaults to the clock. */
59
+ now?: () => string;
60
+ }
61
+ export type ReadOrComputeResult = {
62
+ reused: true;
63
+ record: DerivedState;
64
+ read_receipt: string;
65
+ } | {
66
+ reused: false;
67
+ record: DerivedState;
68
+ write: WriteResult;
69
+ superseded: string | null;
70
+ read_receipt: string;
71
+ };
72
+ /** The server's canonical form (src/core/stateCanonical.ts): keys in code-unit order, undefined
73
+ * dropped, non-finite numbers and `__proto__` refused. Kept in step by test. */
74
+ export declare function canonicalJson(value: unknown): string;
75
+ /** `sha256:<hex>` over the canonical form — the server's stateHash. */
76
+ export declare function stateHash(value: unknown): Promise<string>;
77
+ export declare function readOrCompute(client: ReadOrComputeClient, request: ReadOrComputeRequest): Promise<ReadOrComputeResult>;
@@ -0,0 +1,85 @@
1
+ const DERIVED_SCHEMA = "nuryel.derived/1";
2
+ /** The server's canonical form (src/core/stateCanonical.ts): keys in code-unit order, undefined
3
+ * dropped, non-finite numbers and `__proto__` refused. Kept in step by test. */
4
+ export function canonicalJson(value) {
5
+ return JSON.stringify(canonical(value));
6
+ }
7
+ function canonical(value) {
8
+ if (value === null || typeof value === "string" || typeof value === "boolean")
9
+ return value;
10
+ if (typeof value === "number") {
11
+ if (!Number.isFinite(value))
12
+ throw new Error("canonical form rejects non-finite numbers");
13
+ return value;
14
+ }
15
+ if (Array.isArray(value))
16
+ return value.map(canonical);
17
+ if (typeof value === "object") {
18
+ const out = {};
19
+ for (const key of Object.keys(value).sort()) {
20
+ if (key === "__proto__")
21
+ throw new Error("canonical form rejects reserved key __proto__");
22
+ const v = value[key];
23
+ if (v !== undefined)
24
+ out[key] = canonical(v);
25
+ }
26
+ return out;
27
+ }
28
+ throw new Error(`canonical form rejects ${typeof value}`);
29
+ }
30
+ /** `sha256:<hex>` over the canonical form — the server's stateHash. */
31
+ export async function stateHash(value) {
32
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalJson(value)));
33
+ return `sha256:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")}`;
34
+ }
35
+ function dependencySet(dependencies) {
36
+ return dependencies.map(canonicalJson).sort().join("\n");
37
+ }
38
+ function sameScope(a, b) {
39
+ return !!a && a.kind === b.kind && a.id === b.id;
40
+ }
41
+ export async function readOrCompute(client, request) {
42
+ const { scope, subject, transform_version, dependencies, provenance } = request;
43
+ if (!Array.isArray(dependencies) || dependencies.length === 0)
44
+ throw new Error("readOrCompute: derived state needs at least one dependency (a statement nothing can invalidate is not state)");
45
+ const read = await client.read({ scope, subject, facets: ["derived"] });
46
+ const refs = (read.state_of_record?.current ?? []).filter((ref) => ref.facet === "derived" && sameScope(ref.scope, scope));
47
+ const recordHash = new Map(refs.map((ref) => [ref.id, ref.record_hash]));
48
+ let records = read.records ?? {};
49
+ const unseen = refs.map((ref) => ref.id).filter((id) => !records[id]);
50
+ // Hosts that predate `records` on the read answer by id instead.
51
+ if (unseen.length)
52
+ records = { ...records, ...(await client.records({ scope, ids: unseen })).records };
53
+ const current = refs
54
+ .map((ref) => records[ref.id])
55
+ .filter((r) => !!r && r.schema === DERIVED_SCHEMA && r.subject === subject && r.transform_version === transform_version && r.state === "current" && r.valid_to == null);
56
+ const wanted = dependencySet(dependencies);
57
+ const reusable = current.find((r) => dependencySet(r.dependencies) === wanted);
58
+ if (reusable)
59
+ return { reused: true, record: reusable, read_receipt: read.receipt_id };
60
+ const computed = await request.compute();
61
+ const { content, field_provenance } = typeof computed === "string" ? { content: computed, field_provenance: undefined } : computed;
62
+ if (typeof content !== "string" || content.length === 0)
63
+ throw new Error("readOrCompute: compute must return non-empty content");
64
+ const content_hash = await stateHash(content);
65
+ const computed_at = (request.now ?? (() => new Date().toISOString()))();
66
+ const incumbent = current.find((r) => dependencySet(r.dependencies) !== wanted) ?? null;
67
+ const statement = await stateHash({ scope, subject, transform_version, dependencies: dependencies.map(canonicalJson).sort() });
68
+ const idempotency_key = `derived:${statement.slice(7, 23)}:${content_hash.slice(7, 23)}:${computed_at}`;
69
+ const visibility = request.visibility !== undefined ? request.visibility : incumbent?.visibility;
70
+ const audienceChanges = !!incumbent && canonicalJson(incumbent.visibility ?? null) !== canonicalJson(visibility ?? null);
71
+ const record = {
72
+ ...(visibility ? { visibility } : {}),
73
+ schema: DERIVED_SCHEMA, scope, subject, content, content_hash, dependencies,
74
+ ...(field_provenance ? { field_provenance } : {}),
75
+ transform_version, computed_at, valid_to: null, state: "current", provenance,
76
+ };
77
+ const write = await client.write({
78
+ scope, facet: "derived", record, idempotency_key,
79
+ ...(incumbent ? { supersedes: incumbent.id } : {}),
80
+ ...(audienceChanges ? { expected_version: recordHash.get(incumbent.id) } : {}),
81
+ });
82
+ const stored = (write.record ?? { ...record, id: write.record_id });
83
+ return { reused: false, record: stored, write, superseded: incumbent?.id ?? null, read_receipt: read.receipt_id };
84
+ }
85
+ //# sourceMappingURL=readOrCompute.js.map
@@ -314,3 +314,4 @@ export declare function createStateClient(opts: StateClientOptions): {
314
314
  }>;
315
315
  };
316
316
  export type StateClient = ReturnType<typeof createStateClient>;
317
+ export { readOrCompute, type ReadOrComputeClient, type ReadOrComputeRequest, type ReadOrComputeResult, type ComputedContent } from "./readOrCompute.js";
@@ -62,4 +62,5 @@ export function createStateClient(opts) {
62
62
  health: () => call("GET", "/nuryel/v1/health"),
63
63
  };
64
64
  }
65
+ export { readOrCompute } from "./readOrCompute.js";
65
66
  //# sourceMappingURL=state.js.map
@@ -131,6 +131,7 @@ export interface G2ShadowSweepReport {
131
131
  policy_id: string;
132
132
  error: string;
133
133
  }>;
134
+ retired: string[];
134
135
  skipped_reason: string | null;
135
136
  authority: "none";
136
137
  effects: "shadow_only";
@@ -677,6 +677,7 @@ export class ConstitutionService {
677
677
  const recorded = [];
678
678
  const existing = [];
679
679
  const failures = [];
680
+ const retired = [];
680
681
  if (manifest) {
681
682
  const before = new Set(this.repository.listShadowEvaluations({ privateOnly: true }).map((record) => record.id));
682
683
  for (const policyId of manifest.policy_ids) {
@@ -686,6 +687,12 @@ export class ConstitutionService {
686
687
  if (!policy || publicDuplicate || this.repository.homeOfPolicy(policyId) !== "private" || policy.data_class === "public") {
687
688
  throw new Error("selected policy is not in one exact private-only home");
688
689
  }
690
+ // A retired policy has closed its valid-time window: observing it again is
691
+ // not evidence, only growth. Its recorded history stays untouched.
692
+ if (policy.state === "retired") {
693
+ retired.push(policyId);
694
+ continue;
695
+ }
689
696
  const record = this.recordShadow(policyId, { now: opts.now });
690
697
  if (before.has(record.id))
691
698
  existing.push(record.id);
@@ -705,6 +712,7 @@ export class ConstitutionService {
705
712
  recorded: recorded.sort(),
706
713
  existing: existing.sort(),
707
714
  failures: failures.sort((left, right) => left.policy_id.localeCompare(right.policy_id)),
715
+ retired: retired.sort(),
708
716
  skipped_reason: manifest ? null : "No current private G2 plan; shadow sweep wrote nothing.",
709
717
  authority: "none",
710
718
  effects: "shadow_only",
@@ -93,16 +93,21 @@ function parsedSymbolFor(graphSymbol, parsed) {
93
93
  const base = symbolId(graphSymbol.file, graphSymbol.name, graphSymbol.kind);
94
94
  return matches.find((_symbol, index) => (index === 0 ? base : `${base}_${index}`) === graphSymbol.id) ?? null;
95
95
  }
96
- function spliceBytes(source, replacements) {
97
- let bytes = Buffer.from(source, "utf8");
96
+ /** `start`/`end` are JS string (UTF-16 code unit) indices. Despite their
97
+ * names, parse.ts's startByte/endByte/atByte carry the same units — native
98
+ * tree-sitter indexes the JS string it was handed, not its UTF-8 encoding —
99
+ * so every scan and splice against them must be string-based, never Buffer-
100
+ * based. */
101
+ function spliceChars(source, replacements) {
102
+ let result = source;
98
103
  for (const replacement of [...replacements].sort((a, b) => b.start - a.start)) {
99
- bytes = Buffer.concat([
100
- bytes.subarray(0, replacement.start),
101
- Buffer.from(replacement.text, "utf8"),
102
- bytes.subarray(replacement.end),
103
- ]);
104
+ result = result.slice(0, replacement.start) + replacement.text + result.slice(replacement.end);
104
105
  }
105
- return bytes.toString("utf8");
106
+ return result;
107
+ }
108
+ /** Where a new top-level statement (an import) can be inserted without splitting a shebang line. */
109
+ function insertionPoint(source) {
110
+ return source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
106
111
  }
107
112
  function mutateSource(policy, base, sourceFile, source) {
108
113
  const assertion = policy.assertion;
@@ -145,10 +150,10 @@ function mutateSource(policy, base, sourceFile, source) {
145
150
  const specifier = relativeSpecifier(sourceFile, targetFile);
146
151
  if (parsed.imports.some((candidate) => candidate === specifier))
147
152
  return { error: "mutation-component-import-already-present" };
148
- const insertion = source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
153
+ const insertion = insertionPoint(source);
149
154
  return {
150
155
  file: sourceFile,
151
- source: spliceBytes(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(specifier)}; // hunch deterministic component mutation\n` }]),
156
+ source: spliceChars(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(specifier)}; // hunch deterministic component mutation\n` }]),
152
157
  };
153
158
  }
154
159
  const subject = symbolForSelector(base, assertion.subject);
@@ -161,7 +166,7 @@ function mutateSource(policy, base, sourceFile, source) {
161
166
  if (!definition)
162
167
  return { error: "mutation-subject-definition-unresolved" };
163
168
  if (assertion.kind === "exists") {
164
- return { file: subject.file, source: spliceBytes(source, [{ start: definition.startByte, end: definition.endByte, text: "" }]) };
169
+ return { file: subject.file, source: spliceChars(source, [{ start: definition.startByte, end: definition.endByte, text: "" }]) };
165
170
  }
166
171
  if (assertion.kind === "not-reaches"
167
172
  && assertion.relation.edges.length === 1
@@ -173,10 +178,10 @@ function mutateSource(policy, base, sourceFile, source) {
173
178
  if (parsed.imports.some((specifier) => externalPackage(specifier) === dependency)) {
174
179
  return { error: "mutation-forbidden-import-already-present" };
175
180
  }
176
- const insertion = source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
181
+ const insertion = insertionPoint(source);
177
182
  return {
178
183
  file: subject.file,
179
- source: spliceBytes(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(dependency)}; // hunch deterministic source mutation\n` }]),
184
+ source: spliceChars(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(dependency)}; // hunch deterministic source mutation\n` }]),
180
185
  };
181
186
  }
182
187
  const object = symbolForSelector(base, assertion.object);
@@ -193,19 +198,19 @@ function mutateSource(policy, base, sourceFile, source) {
193
198
  .map((call) => ({ start: call.atByte, end: call.endByte, text: "hunchMutationRemovedCall" }));
194
199
  if (!replacements.length)
195
200
  return { error: "mutation-required-call-unresolved" };
196
- return { file: subject.file, source: spliceBytes(source, replacements) };
201
+ return { file: subject.file, source: spliceChars(source, replacements) };
197
202
  }
198
203
  if (!assertion.relation.edges.includes("calls"))
199
204
  return { error: "mutation-call-edge-not-supported" };
200
- const bytes = Buffer.from(source, "utf8");
201
- const open = bytes.indexOf("{".charCodeAt(0), definition.startByte);
205
+ // String search, matching definition.startByte/endByte's actual units -- see spliceChars' doc comment.
206
+ const open = source.indexOf("{", definition.startByte);
202
207
  if (open < 0 || open >= definition.endByte)
203
208
  return { error: "mutation-subject-body-unsupported" };
204
209
  const replacements = [{ start: open + 1, end: open + 1, text: `\n ${object.name}(); // hunch deterministic source mutation\n` }];
205
210
  if (object.file !== subject.file) {
206
211
  const specifier = relativeSpecifier(subject.file, object.file);
207
212
  if (!parsed.imports.includes(specifier)) {
208
- const insertion = source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
213
+ const insertion = insertionPoint(source);
209
214
  replacements.push({
210
215
  start: insertion,
211
216
  end: insertion,
@@ -215,7 +220,7 @@ function mutateSource(policy, base, sourceFile, source) {
215
220
  }
216
221
  return {
217
222
  file: subject.file,
218
- source: spliceBytes(source, replacements),
223
+ source: spliceChars(source, replacements),
219
224
  };
220
225
  }
221
226
  function removeWorktree(root, hooks, env, checkout) {
@@ -8,6 +8,16 @@
8
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
+ /** One file section of a Codex `apply_patch` payload. */
12
+ export interface HunchPatchFile {
13
+ /** The path as written in the patch (may be absolute). */
14
+ path: string;
15
+ action: "update" | "add" | "delete";
16
+ /** `*** Move to:` destination, when the section renames the file. */
17
+ moved_to?: string;
18
+ /** Only the lines this section adds (`+` prefix stripped). */
19
+ added_lines: string[];
20
+ }
11
21
  export interface HunchToolInput {
12
22
  file_path?: string;
13
23
  new_string?: string;
@@ -17,6 +27,9 @@ export interface HunchToolInput {
17
27
  }>;
18
28
  command?: string;
19
29
  skill?: string;
30
+ /** Codex `apply_patch` only: every file the patch touches. `file_path` is the
31
+ * first entry's path and `content` the raw patch, for single-file consumers. */
32
+ patch_files?: HunchPatchFile[];
20
33
  }
21
34
  /** A provider-neutral observation of the tool result. Output is ephemeral: the
22
35
  * pipeline compares it with bounded expectations but never persists it. */
@@ -41,6 +54,7 @@ export interface HunchHookInput {
41
54
  /** SubagentStart: the delegated agent's type (e.g. "Explore", "Plan"). */
42
55
  agent_type?: string;
43
56
  }
57
+ export declare function parseApplyPatch(patch: string): HunchPatchFile[];
44
58
  /** Parse a provider name supplied by a hook config. Unknown values intentionally
45
59
  * return null so a bad config cannot make an edit fail. */
46
60
  export declare function hookProvider(value: unknown): HookProvider | null;
@@ -49,16 +49,59 @@ 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
+ /** Parse Codex `apply_patch` text into one entry per touched file. Only `+` lines
53
+ * inside a file section count as added: removed (`-`) and context (` `) lines are
54
+ * text the edit takes away or leaves alone, so a content-matched gate must not read
55
+ * them as proposed content. Paths are returned exactly as written in the patch;
56
+ * the CLI normalizes them against the repository root. */
57
+ const PATCH_HEADER = /^\*\*\* (Update|Add|Delete) File: (.+?)\s*$/;
58
+ const PATCH_MOVE = /^\*\*\* Move to: (.+?)\s*$/;
59
+ export function parseApplyPatch(patch) {
60
+ const files = [];
61
+ let current;
62
+ for (const line of patch.split(/\r?\n/)) {
63
+ const header = PATCH_HEADER.exec(line);
64
+ if (header) {
65
+ current = { path: header[2], action: header[1].toLowerCase(), added_lines: [] };
66
+ files.push(current);
67
+ continue;
68
+ }
69
+ if (!current)
70
+ continue;
71
+ const move = PATCH_MOVE.exec(line);
72
+ if (move) {
73
+ current.moved_to = move[1];
74
+ continue;
75
+ }
76
+ // `*** End Patch` / `*** End of File` close sections; they carry no content.
77
+ if (line.startsWith("*** "))
78
+ continue;
79
+ // A unified-diff style file header is not content; apply_patch has none, but
80
+ // a model can still emit one.
81
+ if (/^\+\+\+ (?:[ab]\/|\/dev\/null)/.test(line))
82
+ continue;
83
+ if (line.startsWith("+"))
84
+ current.added_lines.push(line.slice(1));
85
+ }
86
+ return files;
87
+ }
52
88
  /** 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;
89
+ * (`*** Update File: path`). Every touched file is listed in `patch_files` so the
90
+ * pre-edit gate runs per file; the first path stays `file_path` and the whole patch
91
+ * stays `content` for consumers that read the single-file shape.
92
+ *
93
+ * TODO(codex-exec-patch): a Codex code-mode `exec` tool call that embeds an
94
+ * apply_patch does NOT reach this parser: `.codex/hooks.json` (integrations/
95
+ * providers.ts) matches PreToolUse on `apply_patch` only, and `normalizeHookEvent`
96
+ * enables patch parsing only for tool names `apply_patch`/`patch`. No captured
97
+ * payload of such a call exists in the repo, so its shape is not guessed here;
98
+ * capture a real hook payload before extending the matcher or this parser. */
56
99
  function applyPatchInput(raw) {
57
100
  const patch = [raw.input, raw.patch, raw.content].find((v) => typeof v === "string" && /\*\*\* Begin Patch/.test(v));
58
101
  if (!patch)
59
102
  return undefined;
60
- const file = PATCH_FILE.exec(patch)?.[1];
61
- return file ? { file_path: file, content: patch } : undefined;
103
+ const files = parseApplyPatch(patch);
104
+ return files.length ? { file_path: files[0].path, content: patch, patch_files: files } : undefined;
62
105
  }
63
106
  function normalizeToolInput(value, allowPatch = false) {
64
107
  const raw = obj(value);
@@ -94,7 +94,8 @@ function exactChangedPaths(root, baseRevision, resultRevision) {
94
94
  }
95
95
  function exactDiff(root, baseRevision, resultRevision) {
96
96
  const raw = gitBytes(root, [
97
- "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--no-renames", "--unified=2",
97
+ "-c", "core.quotePath=false",
98
+ "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--no-renames", "--unified=2", "--src-prefix=a/", "--dst-prefix=b/",
98
99
  baseRevision, resultRevision, "--",
99
100
  ]);
100
101
  if (raw.byteLength <= MAX_DIFF_BYTES)
@@ -309,6 +310,9 @@ export function deriveChangeProof(root, store, baseRef, resultRef = "HEAD", opti
309
310
  const guardReport = store.buildCheckReport(changed.paths, diff.diff, {
310
311
  strict: true,
311
312
  publicOnly,
313
+ // A diff cut at MAX_DIFF_BYTES is a prefix: content-matched blocking rules over the
314
+ // files it omits fail closed rather than read as compliant (dec_20db57c576).
315
+ diffStatus: diff.gaps.length ? { incomplete: `the guard diff exceeded ${MAX_DIFF_BYTES} bytes and was cut`, truncated: true } : undefined,
312
316
  lastChange: (path) => lastChangeAt(root, change.head_revision, path),
313
317
  });
314
318
  const allStrictBlockerIds = sortedUnique([
@@ -29,6 +29,13 @@ export interface CheckDirect {
29
29
  strictBlocks: boolean;
30
30
  /** If a blocking invariant is downgraded to advisory under strict, why. */
31
31
  downgrade?: "stale" | "low-confidence";
32
+ /** Set when a content-matched blocking invariant could NOT be evaluated because the
33
+ * added lines of some scoped files are missing from the diff (truncated diff, git
34
+ * failure, unreadable file). Reported as a hit, never as compliance: it fails closed. */
35
+ unevaluable?: {
36
+ reason: string;
37
+ files: string[];
38
+ };
32
39
  /** The causal citation (the "why this guard exists") — present when the graph links it. */
33
40
  why?: CausalWhy;
34
41
  }
@@ -44,6 +44,16 @@ export function renderImpact(im, scope) {
44
44
  }
45
45
  return out.join("\n");
46
46
  }
47
+ /** Strict-failure reason fragments for direct invariants: proven hits and the
48
+ * content-matched invariants that could not be evaluated are named separately. */
49
+ function strictBlockerReasons(r) {
50
+ const unevaluable = r.direct.filter((d) => d.strictBlocks && d.unevaluable).length;
51
+ const proven = r.strictBlockers - unevaluable;
52
+ return [
53
+ proven > 0 ? `${proven} high-confidence blocking invariant(s) directly in scope` : "",
54
+ unevaluable > 0 ? `${unevaluable} blocking invariant(s) that could not be evaluated against the complete diff` : "",
55
+ ];
56
+ }
47
57
  /** True when --strict should FAIL the commit/PR. */
48
58
  export function reportFailsStrict(r) {
49
59
  return r.strict && (r.strictBlockers > 0 || r.regBlocking > 0 || r.vetoBlocking > 0);
@@ -93,7 +103,10 @@ export function renderText(r) {
93
103
  const note = r.strict && c.severity === "blocking" && !c.strictBlocks
94
104
  ? c.downgrade === "stale" ? " (advisory: stale)" : " (advisory: low confidence)"
95
105
  : "";
96
- out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${c.files.join(", ")}\n rationale: ${c.rationale || "—"}${whyText(c.why)}`);
106
+ const unevaluable = c.unevaluable
107
+ ? `\n ‼ NOT EVALUATED — ${c.unevaluable.reason}; added lines unavailable for: ${c.unevaluable.files.join(", ")} (fails closed)`
108
+ : "";
109
+ out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${c.files.join(", ")}\n rationale: ${c.rationale || "—"}${unevaluable}${whyText(c.why)}`);
97
110
  }
98
111
  }
99
112
  if (r.near.length) {
@@ -122,7 +135,7 @@ export function renderText(r) {
122
135
  }
123
136
  if (reportFailsStrict(r)) {
124
137
  const reasons = [
125
- r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
138
+ ...strictBlockerReasons(r),
126
139
  r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
127
140
  r.vetoBlocking ? `${r.vetoBlocking} reversed-decision veto(es)` : "",
128
141
  ].filter(Boolean).join(" + ");
@@ -153,6 +166,8 @@ export function renderMarkdown(r) {
153
166
  : "";
154
167
  out.push(`- **[${c.severity}] ${c.statement}** — \`${c.id}\`${note}`);
155
168
  out.push(` - in: ${c.files.map((f) => `\`${f}\``).join(", ")}`);
169
+ if (c.unevaluable)
170
+ out.push(` - ‼ **Not evaluated** — ${c.unevaluable.reason}; added lines unavailable for ${c.unevaluable.files.map((f) => `\`${f}\``).join(", ")} _(fails closed)_`);
156
171
  if (c.rationale)
157
172
  out.push(` - _${c.rationale}_`);
158
173
  for (const line of whyMd(c.why))
@@ -196,7 +211,7 @@ export function renderMarkdown(r) {
196
211
  out.push("---");
197
212
  if (reportFailsStrict(r)) {
198
213
  const reasons = [
199
- r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
214
+ ...strictBlockerReasons(r),
200
215
  r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
201
216
  r.vetoBlocking ? `${r.vetoBlocking} reversed-decision veto(es)` : "",
202
217
  ].filter(Boolean).join(" + ");
@@ -233,6 +248,8 @@ export function renderSarif(r, version, extras = {}) {
233
248
  text += ` — ${c.rationale}`;
234
249
  if (c.downgrade)
235
250
  text += ` (advisory under strict: ${c.downgrade})`;
251
+ if (c.unevaluable)
252
+ text += `\nNOT EVALUATED (fails closed): ${c.unevaluable.reason}; added lines unavailable for ${c.unevaluable.files.join(", ")}`;
236
253
  if (c.why?.decision)
237
254
  text += `\nwhy: “${c.why.decision.title}” (${c.why.decision.id})`;
238
255
  if (c.why?.bug)
@@ -1,5 +1,5 @@
1
1
  import { verdict } from "./checkreport.js";
2
- import { revExists, rangeFiles, rangeDiff } from "../extractors/git.js";
2
+ import { revExists, rangeFiles, rangeGateDiff } from "../extractors/git.js";
3
3
  /** A lower fit score is better. Verdict dominates (pass < warn < block), then the
4
4
  * blocking count, then total advisory hits. Errored candidates sort last. */
5
5
  function fitKey(c) {
@@ -14,7 +14,8 @@ export function compareCandidates(store, root, base, candidates) {
14
14
  const files = rangeFiles(base, root, ref);
15
15
  if (!files.length)
16
16
  return { ...zero, verdict: "pass", error: `no changes vs ${base}` };
17
- const r = store.buildCheckReport(files, rangeDiff(base, root, ref), { strict: true });
17
+ const gate = rangeGateDiff(base, root, ref);
18
+ const r = store.buildCheckReport(files, gate.diff, { strict: true, diffStatus: gate });
18
19
  return {
19
20
  ref,
20
21
  verdict: verdict(r),
@@ -9,12 +9,28 @@ import type { HunchPaths } from "./paths.js";
9
9
  export type Firmness = "off" | "advisory" | "firm" | "strict";
10
10
  export declare const FIRMNESS_LEVELS: readonly Firmness[];
11
11
  export declare const DEFAULT_FIRMNESS: Firmness;
12
+ /** Workspace-ledger knobs (docs/workspace-ledger.md). `publish` decides what a snapshot
13
+ * carries: `branches` (default — label, branches, verdicts, dirty/locked flags, no paths),
14
+ * `full` (worktree paths too), `off` (no record). `publish_public` lets a repo WITHOUT an
15
+ * overlay commit the record into its tracked .hunch/ — off by default: per-machine facts
16
+ * churning the code repo is rarely wanted. */
17
+ export type WorkspacePublish = "full" | "branches" | "off";
18
+ export declare const WORKSPACE_PUBLISH_MODES: readonly WorkspacePublish[];
19
+ export declare const DEFAULT_WORKSPACE_PUBLISH: WorkspacePublish;
20
+ export interface WorkspacesConfig {
21
+ publish: WorkspacePublish;
22
+ stale_after_days: number;
23
+ publish_public: boolean;
24
+ }
12
25
  export interface HunchConfig {
13
26
  firmness: Firmness;
14
27
  /** MCP tool groups beyond the everyday set: `all`, `core`, or `core,nuryel`
15
28
  * (see src/mcp/toolset.ts). Undefined = decide from the root's contents. */
16
29
  mcp_tools?: string;
30
+ workspaces?: Partial<WorkspacesConfig>;
17
31
  }
32
+ /** The effective workspace config: every field present, unknown values ignored. */
33
+ export declare function workspacesConfig(config: HunchConfig): WorkspacesConfig;
18
34
  export declare function isFirmness(v: unknown): v is Firmness;
19
35
  /** Read `.hunch/config.json`. A missing/unparseable file, or an unknown firmness
20
36
  * value, falls back to defaults — the hook must NEVER crash an edit over config. */
@@ -6,6 +6,18 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
6
  import { dirname } from "node:path";
7
7
  export const FIRMNESS_LEVELS = ["off", "advisory", "firm", "strict"];
8
8
  export const DEFAULT_FIRMNESS = "advisory";
9
+ export const WORKSPACE_PUBLISH_MODES = ["full", "branches", "off"];
10
+ export const DEFAULT_WORKSPACE_PUBLISH = "branches";
11
+ /** The effective workspace config: every field present, unknown values ignored. */
12
+ export function workspacesConfig(config) {
13
+ const raw = config.workspaces ?? {};
14
+ const days = Number(raw.stale_after_days);
15
+ return {
16
+ publish: WORKSPACE_PUBLISH_MODES.includes(raw.publish) ? raw.publish : DEFAULT_WORKSPACE_PUBLISH,
17
+ stale_after_days: Number.isInteger(days) && days >= 1 && days <= 3650 ? days : 7,
18
+ publish_public: raw.publish_public === true,
19
+ };
20
+ }
9
21
  function defaults() {
10
22
  return { firmness: DEFAULT_FIRMNESS };
11
23
  }
@@ -22,6 +34,7 @@ export function readConfig(paths) {
22
34
  return {
23
35
  firmness: isFirmness(raw.firmness) ? raw.firmness : DEFAULT_FIRMNESS,
24
36
  ...(typeof raw.mcp_tools === "string" && raw.mcp_tools.trim() ? { mcp_tools: raw.mcp_tools.trim() } : {}),
37
+ ...(raw.workspaces && typeof raw.workspaces === "object" && !Array.isArray(raw.workspaces) ? { workspaces: raw.workspaces } : {}),
25
38
  };
26
39
  }
27
40
  catch {
@@ -0,0 +1,20 @@
1
+ export interface MachineIdentity {
2
+ id: string;
3
+ label: string;
4
+ created_at: string;
5
+ }
6
+ export interface MachinePathOptions {
7
+ env?: NodeJS.ProcessEnv;
8
+ home?: string;
9
+ platform?: NodeJS.Platform;
10
+ }
11
+ export declare function machineFile(opts?: MachinePathOptions): string;
12
+ export declare function defaultMachineLabel(id: string): string;
13
+ /** The machine's identity, minted on first use. An unreadable or invalid file is
14
+ * replaced (a machine that lost its id simply becomes a new machine; the old record
15
+ * ages out as unverified and `hunch workspaces forget` removes it). */
16
+ export declare function loadOrCreateMachine(opts?: MachinePathOptions): MachineIdentity;
17
+ export declare function setMachineLabel(label: string, opts?: MachinePathOptions): MachineIdentity;
18
+ /** A label that equals the hostname or the OS username publishes personal data into a
19
+ * shared store; `doctor` and `label` warn, they do not refuse — the user chose it. */
20
+ export declare function labelLeaksIdentity(label: string): string | null;