@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.
- package/dist/cli/index.js +355 -55
- package/dist/cli/integrations.js +10 -0
- package/dist/cli/serve.js +1 -0
- package/dist/client/readOrCompute.d.ts +77 -0
- package/dist/client/readOrCompute.js +85 -0
- package/dist/client/state.d.ts +1 -0
- package/dist/client/state.js +1 -0
- package/dist/constitution/g2.d.ts +1 -0
- package/dist/constitution/service.js +8 -0
- package/dist/constitution/sourceMutation.js +23 -18
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +48 -5
- package/dist/core/changeProof.js +5 -1
- package/dist/core/checkreport.d.ts +7 -0
- package/dist/core/checkreport.js +20 -3
- package/dist/core/compare.js +3 -2
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +13 -0
- package/dist/core/machine.d.ts +20 -0
- package/dist/core/machine.js +101 -0
- package/dist/core/taskReportEvidence.js +6 -6
- package/dist/core/types.d.ts +67 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +256 -0
- package/dist/core/workspace.js +359 -0
- package/dist/extractors/diff.d.ts +34 -0
- package/dist/extractors/diff.js +147 -5
- package/dist/extractors/git.d.ts +40 -11
- package/dist/extractors/git.js +147 -43
- package/dist/extractors/helm.d.ts +17 -28
- package/dist/extractors/helm.js +12 -12
- package/dist/extractors/indexer.js +171 -7
- package/dist/extractors/k8sManifest.d.ts +59 -0
- package/dist/extractors/k8sManifest.js +507 -0
- package/dist/extractors/workspaces.d.ts +28 -0
- package/dist/extractors/workspaces.js +427 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.d.ts +27 -2
- package/dist/integrations/gitignore.js +103 -17
- package/dist/integrations/hooks.d.ts +63 -7
- package/dist/integrations/hooks.js +350 -38
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +93 -0
- package/dist/integrations/workspaceLedger.js +307 -0
- package/dist/mcp/server.js +59 -5
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +107 -92
- package/dist/serve/mcpHttp.d.ts +27 -0
- package/dist/serve/mcpHttp.js +95 -0
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +83 -35
- package/package.json +1 -1
- 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
|
package/dist/client/state.d.ts
CHANGED
|
@@ -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";
|
package/dist/client/state.js
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
153
|
+
const insertion = insertionPoint(source);
|
|
149
154
|
return {
|
|
150
155
|
file: sourceFile,
|
|
151
|
-
source:
|
|
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:
|
|
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 =
|
|
181
|
+
const insertion = insertionPoint(source);
|
|
177
182
|
return {
|
|
178
183
|
file: subject.file,
|
|
179
|
-
source:
|
|
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:
|
|
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
|
-
|
|
201
|
-
const open =
|
|
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 =
|
|
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:
|
|
223
|
+
source: spliceChars(source, replacements),
|
|
219
224
|
};
|
|
220
225
|
}
|
|
221
226
|
function removeWorktree(root, hooks, env, checkout) {
|
package/dist/core/agenthook.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/agenthook.js
CHANGED
|
@@ -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`).
|
|
54
|
-
*
|
|
55
|
-
|
|
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
|
|
61
|
-
return
|
|
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);
|
package/dist/core/changeProof.js
CHANGED
|
@@ -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
|
-
"
|
|
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
|
}
|
package/dist/core/checkreport.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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)
|
package/dist/core/compare.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { verdict } from "./checkreport.js";
|
|
2
|
-
import { revExists, rangeFiles,
|
|
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
|
|
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),
|
package/dist/core/config.d.ts
CHANGED
|
@@ -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. */
|
package/dist/core/config.js
CHANGED
|
@@ -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;
|