@davesheffer/hunch 1.32.7 → 1.33.0
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 +102 -206
- package/dist/cli/index.js +2 -0
- package/dist/cli/integrations.js +4 -1
- package/dist/cli/serve.js +28 -2
- package/dist/cli/state.d.ts +3 -0
- package/dist/cli/state.js +150 -0
- package/dist/cli/update.js +6 -2
- package/dist/client/state.d.ts +82 -14
- package/dist/client/state.js +16 -2
- package/dist/client/stateProof.d.ts +4 -0
- package/dist/client/stateProof.js +17 -0
- package/dist/constitution/behaviorEvaluator.js +1 -1
- package/dist/constitution/schema.d.ts +2 -2
- package/dist/core/automaticReviewMemory.d.ts +5 -0
- package/dist/core/conventionDelivery.d.ts +8 -0
- package/dist/core/conventionDelivery.js +52 -0
- package/dist/core/fieldProvenance.d.ts +8 -0
- package/dist/core/fieldProvenance.js +72 -0
- package/dist/core/recordVisibility.d.ts +9 -0
- package/dist/core/recordVisibility.js +25 -0
- package/dist/core/stateCanonical.d.ts +3 -0
- package/dist/core/stateCanonical.js +34 -0
- package/dist/core/stateContract.d.ts +122 -7
- package/dist/core/stateContract.js +26 -31
- package/dist/core/stateDelivery.d.ts +3 -3
- package/dist/core/stateDelivery.js +10 -1
- package/dist/core/stateHttp.d.ts +280 -0
- package/dist/core/stateHttp.js +17 -0
- package/dist/core/stateProof.d.ts +13 -0
- package/dist/core/stateProof.js +34 -0
- package/dist/core/stateRecords.d.ts +127 -0
- package/dist/core/stateRecords.js +48 -0
- package/dist/core/types.d.ts +146 -4
- package/dist/core/types.js +8 -2
- package/dist/extractors/git.js +3 -10
- package/dist/integrations/health.d.ts +2 -1
- package/dist/integrations/health.js +53 -16
- package/dist/mcp/server.js +10 -4
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +71 -30
- package/dist/serve/config.d.ts +16 -0
- package/dist/serve/config.js +27 -7
- package/dist/serve/operator.d.ts +4 -0
- package/dist/serve/operator.js +223 -0
- package/dist/serve/stateProof.d.ts +15 -0
- package/dist/serve/stateProof.js +105 -0
- package/dist/store/changeLedger.d.ts +6 -0
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +18 -19
- package/dist/store/stateAccess.d.ts +13 -0
- package/dist/store/stateAccess.js +85 -0
- package/dist/store/stateBinding.d.ts +13 -18
- package/dist/store/stateBinding.js +161 -52
- package/dist/store/stateCapture.js +10 -2
- package/dist/store/stateError.d.ts +12 -0
- package/dist/store/stateError.js +12 -0
- package/dist/store/statePartition.d.ts +9 -0
- package/dist/store/statePartition.js +30 -0
- package/package.json +6 -2
- package/server.json +3 -3
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createStateProofSigner } from '../client/stateProof.js';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { createStateClient, StateClientError } from '../client/state.js';
|
|
5
|
+
import { ReadRequestSchema, WriteRequestSchema, SubscribeRequestSchema, RecordsRequestSchema, ScopeSchema, STATE_FIELD_PROVENANCE_VERSION, STATE_RECORD_VISIBILITY_VERSION } from '../core/stateContract.js';
|
|
6
|
+
const MAX_INPUT_BYTES = 1024 * 1024;
|
|
7
|
+
function scopeFrom(value) {
|
|
8
|
+
const match = /^([a-z]+):(.+)$/.exec(value ?? '');
|
|
9
|
+
if (!match)
|
|
10
|
+
throw new Error('pass --scope kind:id, or supply scope in --input JSON');
|
|
11
|
+
return ScopeSchema.parse({ kind: match[1], id: match[2] });
|
|
12
|
+
}
|
|
13
|
+
async function inputObject(file) {
|
|
14
|
+
let text;
|
|
15
|
+
if (file !== '-') {
|
|
16
|
+
const stat = statSync(file);
|
|
17
|
+
if (!stat.isFile() || stat.size > MAX_INPUT_BYTES)
|
|
18
|
+
throw new Error('input must be a regular JSON file of at most 1 MiB');
|
|
19
|
+
text = readFileSync(file, 'utf8');
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
if (process.stdin.isTTY)
|
|
23
|
+
throw new Error('pipe a JSON request or pass --input <file>');
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let length = 0;
|
|
26
|
+
for await (const chunk of process.stdin) {
|
|
27
|
+
const bytes = Buffer.from(chunk);
|
|
28
|
+
length += bytes.length;
|
|
29
|
+
if (length > MAX_INPUT_BYTES)
|
|
30
|
+
throw new Error('input exceeds 1 MiB');
|
|
31
|
+
chunks.push(bytes);
|
|
32
|
+
}
|
|
33
|
+
text = Buffer.concat(chunks).toString('utf8');
|
|
34
|
+
}
|
|
35
|
+
if (Buffer.byteLength(text) > MAX_INPUT_BYTES)
|
|
36
|
+
throw new Error('input exceeds 1 MiB');
|
|
37
|
+
let value;
|
|
38
|
+
try {
|
|
39
|
+
value = JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error('input must be valid JSON');
|
|
43
|
+
}
|
|
44
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
45
|
+
throw new Error('input must be a JSON request object');
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function connection(options) {
|
|
49
|
+
const timeoutMs = Number(options.timeout);
|
|
50
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000)
|
|
51
|
+
throw new Error('--timeout must be 1..300000 milliseconds');
|
|
52
|
+
const url = new URL(options.url);
|
|
53
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
|
|
54
|
+
throw new Error('--url must be an HTTP(S) base URL without credentials, query or fragment');
|
|
55
|
+
let token = process.env.HUNCH_STATE_TOKEN?.trim();
|
|
56
|
+
if (options.tokenFile) {
|
|
57
|
+
const stat = statSync(options.tokenFile);
|
|
58
|
+
if (!stat.isFile() || stat.size > 8192)
|
|
59
|
+
throw new Error('token file must be a regular file of at most 8 KiB');
|
|
60
|
+
token = readFileSync(options.tokenFile, 'utf8').trim();
|
|
61
|
+
}
|
|
62
|
+
if (!token)
|
|
63
|
+
throw new Error('set HUNCH_STATE_TOKEN or pass --token-file; tokens are not accepted as command arguments');
|
|
64
|
+
let proof;
|
|
65
|
+
if (options.proofKeyFile) {
|
|
66
|
+
const stat = statSync(options.proofKeyFile);
|
|
67
|
+
if (!stat.isFile() || stat.size > 8192)
|
|
68
|
+
throw new Error('proof key must be a regular private-key file of at most 8 KiB');
|
|
69
|
+
proof = createStateProofSigner(readFileSync(options.proofKeyFile, 'utf8'));
|
|
70
|
+
}
|
|
71
|
+
return createStateClient({ baseUrl: options.url, token, timeoutMs, proof });
|
|
72
|
+
}
|
|
73
|
+
export function registerStateCommands(program) {
|
|
74
|
+
const state = program.command('state').description('Read and write a served workspace using the state contract; JSON output')
|
|
75
|
+
.option('--url <url>', 'server base URL (or HUNCH_STATE_URL)', process.env.HUNCH_STATE_URL || 'http://127.0.0.1:7474')
|
|
76
|
+
.option('--token-file <file>', 'read a bearer token from a file; otherwise use HUNCH_STATE_TOKEN')
|
|
77
|
+
.option('--proof-key-file <file>', 'Ed25519 private PEM or JWK file for a key-bound token')
|
|
78
|
+
.option('--timeout <ms>', 'timeout for each HTTP request', '15000')
|
|
79
|
+
.option('--pretty', 'indent JSON output');
|
|
80
|
+
const run = (work) => async () => {
|
|
81
|
+
try {
|
|
82
|
+
const options = state.opts(), result = await work(connection(options));
|
|
83
|
+
process.stdout.write(JSON.stringify(result, null, options.pretty ? 2 : undefined) + '\n');
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const problem = error instanceof StateClientError ? error.problem : {
|
|
87
|
+
type: 'about:blank', title: error instanceof z.ZodError ? 'malformed' : 'client-error', status: 0,
|
|
88
|
+
detail: error instanceof z.ZodError ? error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ') : error instanceof Error ? error.message : 'state request failed',
|
|
89
|
+
};
|
|
90
|
+
process.stderr.write(JSON.stringify(problem) + '\n');
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
state.command('capabilities').description('Show the authenticated principal and supported contract capabilities')
|
|
95
|
+
.option('--scope <kind:id>', 'partition to negotiate; defaults to the token’s first grant')
|
|
96
|
+
.action((options) => run(client => client.capabilities(options.scope ? scopeFrom(options.scope) : undefined))());
|
|
97
|
+
for (const verb of ['read', 'write', 'records', 'subscribe']) {
|
|
98
|
+
const command = state.command(verb).description(verb === 'subscribe' ? 'Poll changes once; use head_seq as the next after_seq and honor resync' : `${verb} using the authenticated state contract`)
|
|
99
|
+
.option('--input <file>', 'complete request JSON; - reads stdin (default for write)');
|
|
100
|
+
if (verb !== 'write')
|
|
101
|
+
command.option('--scope <kind:id>', 'partition when using shortcut options');
|
|
102
|
+
if (verb === 'read')
|
|
103
|
+
command.option('--subject <subject>', 'exact subject or record key').option('--task <text>', 'task phrase for memory delivery');
|
|
104
|
+
if (verb === 'records')
|
|
105
|
+
command.option('--ids <id...>', 'exact record IDs');
|
|
106
|
+
if (verb === 'subscribe')
|
|
107
|
+
command.option('--after <seq>', 'last observed sequence; defaults to zero');
|
|
108
|
+
command.action((options) => run(async (client) => {
|
|
109
|
+
const shortcut = options.scope || options.subject || options.task || options.ids || options.after;
|
|
110
|
+
if (options.input && shortcut)
|
|
111
|
+
throw new Error('use either --input JSON or shortcut options, not both');
|
|
112
|
+
const raw = options.input || verb === 'write' ? await inputObject(options.input ?? '-') : {
|
|
113
|
+
scope: scopeFrom(options.scope),
|
|
114
|
+
...(options.subject !== undefined ? { subject: options.subject } : {}),
|
|
115
|
+
...(options.task !== undefined ? { task: options.task } : {}),
|
|
116
|
+
...(verb === 'records' ? { ids: options.ids } : {}),
|
|
117
|
+
...(verb === 'subscribe' ? { after_seq: Number(options.after ?? 0) } : {}),
|
|
118
|
+
};
|
|
119
|
+
const schemas = {
|
|
120
|
+
read: ReadRequestSchema.omit({ schema: true, principal: true }),
|
|
121
|
+
write: WriteRequestSchema.omit({ schema: true, principal: true }),
|
|
122
|
+
records: RecordsRequestSchema.omit({ schema: true, principal: true }),
|
|
123
|
+
subscribe: SubscribeRequestSchema.omit({ schema: true, principal: true }),
|
|
124
|
+
};
|
|
125
|
+
const request = schemas[verb].parse(raw);
|
|
126
|
+
const caps = await client.capabilities(request.scope);
|
|
127
|
+
const required = [`nuryel.state.${verb}/1`];
|
|
128
|
+
if ('record' in request) {
|
|
129
|
+
if (request.record.field_provenance !== undefined)
|
|
130
|
+
required.push(STATE_FIELD_PROVENANCE_VERSION);
|
|
131
|
+
if (request.record.visibility !== undefined)
|
|
132
|
+
required.push(STATE_RECORD_VISIBILITY_VERSION);
|
|
133
|
+
if (typeof request.record.schema === 'string')
|
|
134
|
+
required.push(request.record.schema);
|
|
135
|
+
}
|
|
136
|
+
const missing = required.filter(capability => !caps.capabilities.includes(capability));
|
|
137
|
+
if (missing.length)
|
|
138
|
+
throw new StateClientError(400, 'unsupported', { type: 'about:blank', title: 'unsupported', status: 400, detail: 'server lacks required capabilities: ' + missing.join(', ') });
|
|
139
|
+
// The chosen request schema matches the verb; state semantics remain server-owned.
|
|
140
|
+
if (verb === 'read')
|
|
141
|
+
return client.read(request);
|
|
142
|
+
if (verb === 'write')
|
|
143
|
+
return client.write(request);
|
|
144
|
+
if (verb === 'records')
|
|
145
|
+
return client.records(request);
|
|
146
|
+
return client.subscribe(request);
|
|
147
|
+
})());
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=state.js.map
|
package/dist/cli/update.js
CHANGED
|
@@ -59,8 +59,12 @@ export function updateHunch(root, opts = {}, run = (args, capture) => runNpm(roo
|
|
|
59
59
|
if (!opts.dryRun)
|
|
60
60
|
run(args);
|
|
61
61
|
}
|
|
62
|
-
if (!opts.dryRun)
|
|
63
|
-
log("Hunch updated; repository
|
|
62
|
+
if (!opts.dryRun) {
|
|
63
|
+
log("Hunch updated; repository configuration check passed. Restart or reconnect active harnesses to load the new MCP version. Runtime hook delivery is not verified by this check.");
|
|
64
|
+
if (existsSync(join(root, ".codex", "hooks.json"))) {
|
|
65
|
+
log("Codex: open /hooks to review and trust any changed commands, then start a new session. Command changes require renewed trust; Hunch does not grant it automatically.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
64
68
|
}
|
|
65
69
|
export function registerUpdateCommand(program) {
|
|
66
70
|
program.command("update")
|
package/dist/client/state.d.ts
CHANGED
|
@@ -3,21 +3,16 @@
|
|
|
3
3
|
* Import from `@davesheffer/hunch/state`. The principal is the bearer token's; the request
|
|
4
4
|
* shapes are the contract's minus `schema` and `principal`.
|
|
5
5
|
*/
|
|
6
|
-
import type { ReadRequest, SubscribeRequest, WriteRequest,
|
|
6
|
+
import type { ReadRequest, SubscribeRequest, WriteRequest, Scope } from "../core/stateContract.js";
|
|
7
7
|
import type { DeliveryEnvelope } from "../core/delivery.js";
|
|
8
8
|
import type { CaptureRequest, CaptureBatchRequest } from "../core/stateContract.js";
|
|
9
|
+
import type { SubscribeResponse } from '../store/stateBinding.js';
|
|
9
10
|
export type ClientReadRequest = Omit<ReadRequest, "schema" | "principal">;
|
|
10
11
|
export type ClientWriteRequest = Omit<WriteRequest, "schema" | "principal" | "expected_version"> & {
|
|
11
12
|
expected_version?: string | number | null;
|
|
12
13
|
};
|
|
13
14
|
export type ClientSubscribeRequest = Omit<SubscribeRequest, "schema" | "principal">;
|
|
14
|
-
export
|
|
15
|
-
schema: string;
|
|
16
|
-
scope: Scope;
|
|
17
|
-
head_seq: number;
|
|
18
|
-
events: ChangeEvent[];
|
|
19
|
-
filtered: boolean;
|
|
20
|
-
}
|
|
15
|
+
export type ClientSubscribeResponse = SubscribeResponse;
|
|
21
16
|
export interface StateProblem {
|
|
22
17
|
type: string;
|
|
23
18
|
title: string;
|
|
@@ -36,7 +31,14 @@ export declare class StateClientError extends Error {
|
|
|
36
31
|
readonly problem: StateProblem;
|
|
37
32
|
constructor(status: number, code: string, problem: StateProblem);
|
|
38
33
|
}
|
|
34
|
+
export interface StateProofRequest {
|
|
35
|
+
method: string;
|
|
36
|
+
url: string;
|
|
37
|
+
token: string;
|
|
38
|
+
nonce?: string;
|
|
39
|
+
}
|
|
39
40
|
export interface StateClientOptions {
|
|
41
|
+
proof?: (request: StateProofRequest) => Promise<string>;
|
|
40
42
|
baseUrl: string;
|
|
41
43
|
token: string;
|
|
42
44
|
fetch?: typeof fetch;
|
|
@@ -64,7 +66,7 @@ export declare function createStateClient(opts: StateClientOptions): {
|
|
|
64
66
|
state_of_record: {
|
|
65
67
|
subject: string;
|
|
66
68
|
current: {
|
|
67
|
-
facet: "decisions" | "constraints" | "bugs" | "findings" | "receipts" | "commitments" | "derived" | "entities" | "relationships";
|
|
69
|
+
facet: "decisions" | "constraints" | "bugs" | "findings" | "receipts" | "commitments" | "derived" | "entities" | "relationships" | "conventions";
|
|
68
70
|
id: string;
|
|
69
71
|
record_hash: string;
|
|
70
72
|
scope: {
|
|
@@ -73,7 +75,7 @@ export declare function createStateClient(opts: StateClientOptions): {
|
|
|
73
75
|
};
|
|
74
76
|
}[];
|
|
75
77
|
in_force: {
|
|
76
|
-
facet: "decisions" | "constraints" | "bugs" | "findings" | "receipts" | "commitments" | "derived" | "entities" | "relationships";
|
|
78
|
+
facet: "decisions" | "constraints" | "bugs" | "findings" | "receipts" | "commitments" | "derived" | "entities" | "relationships" | "conventions";
|
|
77
79
|
id: string;
|
|
78
80
|
record_hash: string;
|
|
79
81
|
scope: {
|
|
@@ -82,7 +84,7 @@ export declare function createStateClient(opts: StateClientOptions): {
|
|
|
82
84
|
};
|
|
83
85
|
}[];
|
|
84
86
|
done: {
|
|
85
|
-
facet: "decisions" | "constraints" | "bugs" | "findings" | "receipts" | "commitments" | "derived" | "entities" | "relationships";
|
|
87
|
+
facet: "decisions" | "constraints" | "bugs" | "findings" | "receipts" | "commitments" | "derived" | "entities" | "relationships" | "conventions";
|
|
86
88
|
id: string;
|
|
87
89
|
record_hash: string;
|
|
88
90
|
scope: {
|
|
@@ -116,7 +118,7 @@ export declare function createStateClient(opts: StateClientOptions): {
|
|
|
116
118
|
})[];
|
|
117
119
|
invalidated_by: string[];
|
|
118
120
|
observed?: {
|
|
119
|
-
facet: "decisions" | "constraints" | "bugs" | "findings" | "receipts" | "commitments" | "derived" | "entities" | "relationships";
|
|
121
|
+
facet: "decisions" | "constraints" | "bugs" | "findings" | "receipts" | "commitments" | "derived" | "entities" | "relationships" | "conventions";
|
|
120
122
|
id: string;
|
|
121
123
|
record_hash: string;
|
|
122
124
|
scope: {
|
|
@@ -139,6 +141,24 @@ export declare function createStateClient(opts: StateClientOptions): {
|
|
|
139
141
|
kind: "repository" | "organization" | "team" | "user";
|
|
140
142
|
id: string;
|
|
141
143
|
}[];
|
|
144
|
+
conventions?: {
|
|
145
|
+
advisory: true;
|
|
146
|
+
items: {
|
|
147
|
+
ref: {
|
|
148
|
+
facet: "decisions" | "constraints" | "bugs" | "findings" | "receipts" | "commitments" | "derived" | "entities" | "relationships" | "conventions";
|
|
149
|
+
id: string;
|
|
150
|
+
record_hash: string;
|
|
151
|
+
scope: {
|
|
152
|
+
kind: "repository" | "organization" | "team" | "user";
|
|
153
|
+
id: string;
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
key: string;
|
|
157
|
+
conflict: boolean;
|
|
158
|
+
currentness: "stale" | "recorded";
|
|
159
|
+
}[];
|
|
160
|
+
truncated: boolean;
|
|
161
|
+
} | undefined;
|
|
142
162
|
records?: Record<string, Record<string, unknown>> | undefined;
|
|
143
163
|
scopes?: {
|
|
144
164
|
kind: "repository" | "organization" | "team" | "user";
|
|
@@ -223,7 +243,55 @@ export declare function createStateClient(opts: StateClientOptions): {
|
|
|
223
243
|
message: string;
|
|
224
244
|
})[] | undefined;
|
|
225
245
|
}>;
|
|
226
|
-
subscribe: (request: ClientSubscribeRequest) => Promise<
|
|
246
|
+
subscribe: (request: ClientSubscribeRequest) => Promise<{
|
|
247
|
+
schema: "nuryel.state.subscribe/1";
|
|
248
|
+
scope: {
|
|
249
|
+
kind: "repository" | "organization" | "team" | "user";
|
|
250
|
+
id: string;
|
|
251
|
+
};
|
|
252
|
+
head_seq: number;
|
|
253
|
+
events: {
|
|
254
|
+
schema: "nuryel.state.subscribe/1";
|
|
255
|
+
seq: number;
|
|
256
|
+
at: string;
|
|
257
|
+
scope: {
|
|
258
|
+
kind: "repository" | "organization" | "team" | "user";
|
|
259
|
+
id: string;
|
|
260
|
+
};
|
|
261
|
+
facet: "decisions" | "constraints" | "bugs" | "findings" | "receipts" | "commitments" | "derived" | "entities" | "relationships" | "conventions";
|
|
262
|
+
record_id: string;
|
|
263
|
+
record_hash: string;
|
|
264
|
+
change: "updated" | "retired" | "superseded" | "created" | "invalidated";
|
|
265
|
+
invalidates: string[];
|
|
266
|
+
visibility?: {
|
|
267
|
+
owner: string;
|
|
268
|
+
readers: string[];
|
|
269
|
+
writers: string[];
|
|
270
|
+
} | undefined;
|
|
271
|
+
subject?: string | undefined;
|
|
272
|
+
cause?: {
|
|
273
|
+
kind: "receipt";
|
|
274
|
+
receipt_id: string;
|
|
275
|
+
} | {
|
|
276
|
+
kind: "external";
|
|
277
|
+
ref: {
|
|
278
|
+
system: string;
|
|
279
|
+
object_type: string;
|
|
280
|
+
object_key: string;
|
|
281
|
+
observed_at: string;
|
|
282
|
+
version?: string | undefined;
|
|
283
|
+
content_hash?: string | undefined;
|
|
284
|
+
locator?: string | undefined;
|
|
285
|
+
};
|
|
286
|
+
} | {
|
|
287
|
+
kind: "write";
|
|
288
|
+
principal: string;
|
|
289
|
+
} | undefined;
|
|
290
|
+
}[];
|
|
291
|
+
filtered: boolean;
|
|
292
|
+
floor_seq: number;
|
|
293
|
+
resync: boolean;
|
|
294
|
+
}>;
|
|
227
295
|
records: (request: {
|
|
228
296
|
scope: Scope;
|
|
229
297
|
ids: string[];
|
|
@@ -234,7 +302,7 @@ export declare function createStateClient(opts: StateClientOptions): {
|
|
|
234
302
|
id: string;
|
|
235
303
|
};
|
|
236
304
|
records: Record<string, Record<string, unknown>>;
|
|
237
|
-
facets: Record<string, "decisions" | "constraints" | "bugs" | "findings" | "receipts" | "commitments" | "derived" | "entities" | "relationships">;
|
|
305
|
+
facets: Record<string, "decisions" | "constraints" | "bugs" | "findings" | "receipts" | "commitments" | "derived" | "entities" | "relationships" | "conventions">;
|
|
238
306
|
missing: string[];
|
|
239
307
|
denied: string[];
|
|
240
308
|
}>;
|
package/dist/client/state.js
CHANGED
|
@@ -15,16 +15,30 @@ export function createStateClient(opts) {
|
|
|
15
15
|
const base = opts.baseUrl.replace(/\/+$/, "");
|
|
16
16
|
const doFetch = opts.fetch ?? fetch;
|
|
17
17
|
const timeoutMs = opts.timeoutMs ?? 15_000;
|
|
18
|
+
let nonce;
|
|
18
19
|
async function call(method, path, body) {
|
|
19
20
|
const controller = new AbortController();
|
|
20
21
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
21
22
|
try {
|
|
22
|
-
const
|
|
23
|
+
const url = `${base}${path}`;
|
|
24
|
+
const request = async () => doFetch(url, {
|
|
23
25
|
method,
|
|
24
|
-
headers: { authorization:
|
|
26
|
+
headers: { authorization: `${opts.proof ? 'DPoP' : 'Bearer'} ${opts.token}`, ...(opts.proof ? { dpop: await opts.proof({ method, url, token: opts.token, nonce }) } : {}), ...(body !== undefined ? { "content-type": "application/json" } : {}) },
|
|
25
27
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
26
28
|
signal: controller.signal,
|
|
29
|
+
redirect: "error",
|
|
27
30
|
});
|
|
31
|
+
let response = await request();
|
|
32
|
+
// This one retry is an authentication challenge before any operation runs.
|
|
33
|
+
// Idempotency/conflict/network failures never trigger automatic write retries.
|
|
34
|
+
if (opts.proof && response.status === 401 && response.headers.has('dpop-nonce')) {
|
|
35
|
+
const challenge = await response.clone().json().catch(() => null);
|
|
36
|
+
if (challenge?.title === 'use_dpop_nonce') {
|
|
37
|
+
nonce = response.headers.get('dpop-nonce');
|
|
38
|
+
await response.arrayBuffer();
|
|
39
|
+
response = await request();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
28
42
|
const text = await response.text();
|
|
29
43
|
const parsed = text ? JSON.parse(text) : {};
|
|
30
44
|
if (!response.ok) {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Optional Node signer. The fetch-only client stays usable in other runtimes. */
|
|
2
|
+
import { type KeyObject } from 'node:crypto';
|
|
3
|
+
import type { StateProofRequest } from './state.js';
|
|
4
|
+
export declare function createStateProofSigner(input: string | KeyObject): (request: StateProofRequest) => Promise<string>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Optional Node signer. The fetch-only client stays usable in other runtimes. */
|
|
2
|
+
import { createPrivateKey, createPublicKey, randomUUID, sign } from 'node:crypto';
|
|
3
|
+
import { ProofPublicKeySchema, proofTarget, tokenProofHash } from '../core/stateProof.js';
|
|
4
|
+
export function createStateProofSigner(input) {
|
|
5
|
+
const key = typeof input === 'string' ? (input.trimStart().startsWith('{') ? createPrivateKey({ key: JSON.parse(input), format: 'jwk' }) : createPrivateKey(input)) : input;
|
|
6
|
+
if (key.type !== 'private' || key.asymmetricKeyType !== 'ed25519')
|
|
7
|
+
throw new Error('proof signing requires an Ed25519 private key');
|
|
8
|
+
const jwk = ProofPublicKeySchema.parse(createPublicKey(key).export({ format: 'jwk' }));
|
|
9
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
10
|
+
const header = encode({ typ: 'dpop+jwt', alg: 'EdDSA', jwk });
|
|
11
|
+
return async ({ method, url, token, nonce }) => {
|
|
12
|
+
const claims = encode({ jti: randomUUID(), htm: method, htu: proofTarget(url), iat: Math.floor(Date.now() / 1000), ath: tokenProofHash(token), ...(nonce ? { nonce } : {}) });
|
|
13
|
+
const message = `${header}.${claims}`;
|
|
14
|
+
return `${message}.${sign(null, Buffer.from(message), key).toString('base64url')}`;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=stateProof.js.map
|
|
@@ -132,7 +132,7 @@ export function evaluateExecutableBehaviorPolicy(root, policy, opts = {}) {
|
|
|
132
132
|
if (!existsSync(join(root, ".hunch-cache", "behavior-deps"))) {
|
|
133
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
|
-
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
|
|
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(", ")}); restore and validate the exact pinned cache first. If dependency inputs changed, prepare a replacement behavior candidate and replay; human selection, activation and retirement remain required. Re-proving an active policy does not refresh its pins (docs/behavior-policy-recovery.md)`);
|
|
136
136
|
}
|
|
137
137
|
const session = mkdtempSync(join(tmpdir(), "hunch-behavior-policy-"));
|
|
138
138
|
const hooks = join(session, "hooks-disabled");
|
|
@@ -468,6 +468,7 @@ export type PolicyAssertion = z.infer<typeof PolicyAssertionSchema>;
|
|
|
468
468
|
export declare const PolicyAuditEventSchema: z.ZodObject<{
|
|
469
469
|
action: z.ZodEnum<{
|
|
470
470
|
retired: "retired";
|
|
471
|
+
withdrawn: "withdrawn";
|
|
471
472
|
rejected: "rejected";
|
|
472
473
|
compiled: "compiled";
|
|
473
474
|
repaired: "repaired";
|
|
@@ -477,7 +478,6 @@ export declare const PolicyAuditEventSchema: z.ZodObject<{
|
|
|
477
478
|
approved_advisory: "approved_advisory";
|
|
478
479
|
approved_blocking: "approved_blocking";
|
|
479
480
|
demoted: "demoted";
|
|
480
|
-
withdrawn: "withdrawn";
|
|
481
481
|
}>;
|
|
482
482
|
actor_kind: z.ZodEnum<{
|
|
483
483
|
system: "system";
|
|
@@ -661,6 +661,7 @@ export declare const PolicySpecSchema: z.ZodObject<{
|
|
|
661
661
|
audit: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
662
662
|
action: z.ZodEnum<{
|
|
663
663
|
retired: "retired";
|
|
664
|
+
withdrawn: "withdrawn";
|
|
664
665
|
rejected: "rejected";
|
|
665
666
|
compiled: "compiled";
|
|
666
667
|
repaired: "repaired";
|
|
@@ -670,7 +671,6 @@ export declare const PolicySpecSchema: z.ZodObject<{
|
|
|
670
671
|
approved_advisory: "approved_advisory";
|
|
671
672
|
approved_blocking: "approved_blocking";
|
|
672
673
|
demoted: "demoted";
|
|
673
|
-
withdrawn: "withdrawn";
|
|
674
674
|
}>;
|
|
675
675
|
actor_kind: z.ZodEnum<{
|
|
676
676
|
system: "system";
|
|
@@ -55,6 +55,11 @@ export declare function automateReviewMemory(options: {
|
|
|
55
55
|
evidence: string[];
|
|
56
56
|
last_verified?: string | undefined;
|
|
57
57
|
};
|
|
58
|
+
visibility?: {
|
|
59
|
+
owner: string;
|
|
60
|
+
readers: string[];
|
|
61
|
+
writers: string[];
|
|
62
|
+
} | undefined;
|
|
58
63
|
valid_from?: string | undefined;
|
|
59
64
|
}[];
|
|
60
65
|
}>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Explicit conventions alongside repository DNA. No scope wins and no policy is activated. */
|
|
2
|
+
import type { Convention, Scope } from './stateRecords.js';
|
|
3
|
+
import { type ConventionDelivery } from './stateContract.js';
|
|
4
|
+
import type { DeliverySupplement } from './delivery.js';
|
|
5
|
+
export declare function conventionCurrentness(record: Convention, resolve?: (id: string, scope: Scope) => unknown, now?: number): 'recorded' | 'stale';
|
|
6
|
+
export declare function conventionDelivery(records: readonly Convention[], resolve?: (id: string, scope: Scope) => unknown, verified?: ReadonlyMap<string, "recorded" | "stale">): ConventionDelivery | undefined;
|
|
7
|
+
/** The regular brief uses its existing token budget and keeps Project DNA's separate identity. */
|
|
8
|
+
export declare function conventionSupplements(records: readonly Convention[], resolve?: (id: string, scope: Scope) => unknown): DeliverySupplement[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { scopePath, stateHash } from './stateContract.js';
|
|
2
|
+
import { compareCodeUnits } from './canonicalOrder.js';
|
|
3
|
+
export function conventionCurrentness(record, resolve, now = Date.now()) {
|
|
4
|
+
if (record.status === 'stale' || record.status === 'withdrawn' || record.valid_to !== null || Date.parse(record.valid_from) > now || Date.parse(record.review_by) <= now)
|
|
5
|
+
return 'stale';
|
|
6
|
+
for (const source of record.sources)
|
|
7
|
+
if (source.kind === 'record') {
|
|
8
|
+
const held = resolve?.(source.id, source.scope ?? record.scope);
|
|
9
|
+
if (!held || stateHash(held) !== source.record_hash || held.valid_to != null || held.state === 'stale' || held.status === 'stale' || held.status === 'withdrawn')
|
|
10
|
+
return 'stale';
|
|
11
|
+
}
|
|
12
|
+
return 'recorded';
|
|
13
|
+
}
|
|
14
|
+
export function conventionDelivery(records, resolve, verified) {
|
|
15
|
+
const currentness = (record) => verified?.get(record.id) ?? conventionCurrentness(record, resolve);
|
|
16
|
+
const candidates = [...new Map(records.map(r => [r.id, r])).values()].filter(r => r.valid_to === null && r.status !== 'withdrawn').sort((a, b) => compareCodeUnits(a.key, b.key) || compareCodeUnits(scopePath(a.scope), scopePath(b.scope)) || compareCodeUnits(a.id, b.id));
|
|
17
|
+
if (!candidates.length)
|
|
18
|
+
return undefined;
|
|
19
|
+
const values = new Map();
|
|
20
|
+
for (const record of candidates)
|
|
21
|
+
if (currentness(record) !== 'stale') {
|
|
22
|
+
const set = values.get(record.key) ?? new Set();
|
|
23
|
+
set.add(record.value);
|
|
24
|
+
values.set(record.key, set);
|
|
25
|
+
}
|
|
26
|
+
let bytes = 0;
|
|
27
|
+
const selected = [];
|
|
28
|
+
for (const record of candidates) {
|
|
29
|
+
const size = Buffer.byteLength(JSON.stringify(record));
|
|
30
|
+
if (selected.length >= 16 || bytes + size > 16_384)
|
|
31
|
+
break;
|
|
32
|
+
selected.push(record);
|
|
33
|
+
bytes += size;
|
|
34
|
+
}
|
|
35
|
+
return { advisory: true, items: selected.map(record => ({ ref: { facet: 'conventions', id: record.id, record_hash: stateHash(record), scope: record.scope }, key: record.key,
|
|
36
|
+
currentness: currentness(record), conflict: (values.get(record.key)?.size ?? 0) > 1 })), truncated: selected.length < candidates.length };
|
|
37
|
+
}
|
|
38
|
+
/** The regular brief uses its existing token budget and keeps Project DNA's separate identity. */
|
|
39
|
+
export function conventionSupplements(records, resolve) {
|
|
40
|
+
const delivery = conventionDelivery(records, resolve);
|
|
41
|
+
if (!delivery)
|
|
42
|
+
return [];
|
|
43
|
+
const byId = new Map(records.map(r => [r.id, r]));
|
|
44
|
+
return [{ id: 'explicit-conventions', kind: 'conventions', priority: 424,
|
|
45
|
+
text: `EXPLICIT CONVENTIONS — advisory; scope is context, never precedence or policy authority. Resolve conflicting values before acting.${delivery.truncated || delivery.items.length > 8 ? ' Delivery is incomplete; more records exist.' : ''}` },
|
|
46
|
+
...delivery.items.slice(0, 8).map((item, i) => {
|
|
47
|
+
const record = byId.get(item.ref.id);
|
|
48
|
+
return { id: record.id, kind: 'convention', priority: 423 - i,
|
|
49
|
+
text: `${scopePath(record.scope)} · ${record.key} · ${record.status}/${item.currentness}${item.conflict ? ' · CONFLICT' : ''}: ${record.value.slice(0, 300)} (revision ${item.ref.record_hash}; sources ${record.sources.length}; review by ${record.review_by})` };
|
|
50
|
+
})];
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=conventionDelivery.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DerivedState, FieldSelector } from "./stateRecords.js";
|
|
2
|
+
/** Resolve exactly one scalar JSON field or one Unicode-code-point text range.
|
|
3
|
+
* No normalization, inherited properties, array aliases or substring guessing. */
|
|
4
|
+
export declare function fieldCitationValue(content: string, selector: FieldSelector): string | number | boolean | null;
|
|
5
|
+
/** This is structural traceability, not a check that a source supports a claim. */
|
|
6
|
+
export declare function assertFieldProvenance(record: Pick<DerivedState, "content" | "dependencies" | "field_provenance">): void;
|
|
7
|
+
/** Bounded, plain-text source map for assistant clients; structured records remain complete. */
|
|
8
|
+
export declare function fieldCitationText(record: DerivedState): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { stateHash } from "./stateCanonical.js";
|
|
2
|
+
/** Resolve exactly one scalar JSON field or one Unicode-code-point text range.
|
|
3
|
+
* No normalization, inherited properties, array aliases or substring guessing. */
|
|
4
|
+
export function fieldCitationValue(content, selector) {
|
|
5
|
+
if (selector.kind === "text") {
|
|
6
|
+
const points = Array.from(content);
|
|
7
|
+
if (!Number.isInteger(selector.start) || !Number.isInteger(selector.end) || selector.start < 0 || selector.end <= selector.start || selector.end > points.length)
|
|
8
|
+
throw new Error("text citation range must be nonempty and within the content's Unicode code points");
|
|
9
|
+
return points.slice(selector.start, selector.end).join("");
|
|
10
|
+
}
|
|
11
|
+
if (!/^(?:\/(?:[^~/]|~[01])*)*$/.test(selector.path))
|
|
12
|
+
throw new Error("citation path must be an escaped JSON Pointer");
|
|
13
|
+
let value;
|
|
14
|
+
try {
|
|
15
|
+
value = JSON.parse(content);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error("JSON field citations require JSON content");
|
|
19
|
+
}
|
|
20
|
+
for (const encoded of selector.path === "" ? [] : selector.path.slice(1).split("/")) {
|
|
21
|
+
const key = encoded.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
22
|
+
if (value === null || typeof value !== "object" || (Array.isArray(value) && !/^(0|[1-9][0-9]*)$/.test(key)) || !Object.hasOwn(value, key))
|
|
23
|
+
throw new Error(`citation path ${selector.path} does not name an existing field`);
|
|
24
|
+
value = value[key];
|
|
25
|
+
}
|
|
26
|
+
if (value === null || typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value)))
|
|
27
|
+
return value;
|
|
28
|
+
throw new Error("JSON field citations must name a scalar value, not an object or array");
|
|
29
|
+
}
|
|
30
|
+
/** This is structural traceability, not a check that a source supports a claim. */
|
|
31
|
+
export function assertFieldProvenance(record) {
|
|
32
|
+
if (!record.field_provenance)
|
|
33
|
+
return;
|
|
34
|
+
const dependencies = new Set(record.dependencies.map(stateHash));
|
|
35
|
+
const selectors = new Set();
|
|
36
|
+
for (const [index, citation] of record.field_provenance.entries()) {
|
|
37
|
+
try {
|
|
38
|
+
const key = stateHash(citation.selector);
|
|
39
|
+
if (selectors.has(key))
|
|
40
|
+
throw new Error("duplicate citation selector; combine its dependency hashes in one entry");
|
|
41
|
+
selectors.add(key);
|
|
42
|
+
const value = fieldCitationValue(record.content, citation.selector);
|
|
43
|
+
if (stateHash(value) !== citation.value_hash)
|
|
44
|
+
throw new Error("citation value_hash does not match the selected content; rebuild the citation after editing");
|
|
45
|
+
if (new Set(citation.dependency_hashes).size !== citation.dependency_hashes.length)
|
|
46
|
+
throw new Error("duplicate citation dependency hash");
|
|
47
|
+
if (!citation.dependency_hashes.length || citation.dependency_hashes.some(hash => !dependencies.has(hash)))
|
|
48
|
+
throw new Error("every citation dependency_hash must name an existing summary dependency");
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw new Error(`field_provenance[${index}]: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Bounded, plain-text source map for assistant clients; structured records remain complete. */
|
|
56
|
+
export function fieldCitationText(record) {
|
|
57
|
+
if (!record.field_provenance?.length)
|
|
58
|
+
return "";
|
|
59
|
+
const dependencies = new Map(record.dependencies.map(dep => [stateHash(dep), dep]));
|
|
60
|
+
const source = (dep) => !dep ? "unavailable source" : dep.kind === "external"
|
|
61
|
+
? `${dep.ref.system} ${dep.ref.object_type}:${dep.ref.object_key}` : dep.kind === "record"
|
|
62
|
+
? `record ${dep.id}${dep.scope ? ` in ${dep.scope.kind}/${dep.scope.id}` : ""}` : `schema ${dep.name}`;
|
|
63
|
+
const lines = record.field_provenance.slice(0, 8).map(citation => {
|
|
64
|
+
const selector = citation.selector;
|
|
65
|
+
const target = selector.kind === "text" ? `text ${selector.start}–${selector.end} (Unicode code points)` : `field ${selector.path || "(root)"}`;
|
|
66
|
+
const value = JSON.stringify(fieldCitationValue(record.content, selector));
|
|
67
|
+
const sources = citation.dependency_hashes.slice(0, 4).map(hash => source(dependencies.get(hash)).slice(0, 200)).join("; ");
|
|
68
|
+
return ` ${target}: ${value.slice(0, 200)}${value.length > 200 ? "…" : ""} ← ${sources}${citation.dependency_hashes.length > 4 ? "; more sources in structured record" : ""}`;
|
|
69
|
+
});
|
|
70
|
+
return `\n Writer-supplied field citations (traceability, not verified support or freshness):\n${lines.join("\n")}${record.field_provenance.length > 8 ? "\n More citations in structured record." : ""}`;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=fieldProvenance.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Inline policy stays bound to the record revision and its atomic JSON write. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export declare const RecordVisibilitySchema: z.ZodObject<{
|
|
4
|
+
owner: z.ZodString;
|
|
5
|
+
readers: z.ZodArray<z.ZodString>;
|
|
6
|
+
writers: z.ZodArray<z.ZodString>;
|
|
7
|
+
}, z.core.$strict>;
|
|
8
|
+
export type RecordVisibility = z.infer<typeof RecordVisibilitySchema>;
|
|
9
|
+
export declare function visibilityAllows(record: unknown, id: string, mode?: 'read' | 'write'): boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Inline policy stays bound to the record revision and its atomic JSON write. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const principalId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/);
|
|
4
|
+
export const RecordVisibilitySchema = z.object({
|
|
5
|
+
owner: principalId,
|
|
6
|
+
readers: z.array(principalId).max(256),
|
|
7
|
+
writers: z.array(principalId).max(256),
|
|
8
|
+
}).strict().superRefine((value, ctx) => {
|
|
9
|
+
if (new Set(value.readers).size !== value.readers.length || new Set(value.writers).size !== value.writers.length)
|
|
10
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'visibility lists must contain distinct principal IDs' });
|
|
11
|
+
if (value.writers.some(id => id !== value.owner && !value.readers.includes(id)))
|
|
12
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'every writer must also be a reader' });
|
|
13
|
+
});
|
|
14
|
+
export function visibilityAllows(record, id, mode = 'read') {
|
|
15
|
+
if (!record || typeof record !== 'object')
|
|
16
|
+
return false;
|
|
17
|
+
const raw = record.visibility;
|
|
18
|
+
if (raw === undefined)
|
|
19
|
+
return true;
|
|
20
|
+
const parsed = RecordVisibilitySchema.safeParse(raw);
|
|
21
|
+
if (!parsed.success)
|
|
22
|
+
return false;
|
|
23
|
+
return parsed.data.owner === id || (mode === 'read' ? parsed.data.readers : parsed.data.writers).includes(id);
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=recordVisibility.js.map
|