@davesheffer/hunch 1.32.8 → 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 +9 -3
- package/dist/cli/index.js +2 -0
- package/dist/cli/serve.js +28 -2
- package/dist/cli/state.d.ts +3 -0
- package/dist/cli/state.js +150 -0
- 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/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 +5 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -41,7 +41,9 @@ Hunch works with Claude Code, Codex, Cursor, VS Code/Copilot, Windsurf, Antigrav
|
|
|
41
41
|
| Check the rules your team chose | Evaluates supported constraints and code relationships without a model in the blocking path. |
|
|
42
42
|
| See what happened during a task | Separates delivered memory, the agent's reported use, rule results, and observed command results in a contribution report. |
|
|
43
43
|
| Share project memory with teammates | Keeps repository memory in Git, with an optional dedicated private memory repository. |
|
|
44
|
-
| Share work state across agents | Serves authorized records
|
|
44
|
+
| Share work state across agents | Serves authorized records through HTTP, MCP, the state CLI, and typed TypeScript and Python clients. |
|
|
45
|
+
| Inspect what the agents know | A read-only browser view shows current records, commitments, completed work and writer-supplied citations. |
|
|
46
|
+
| Keep access and conventions explicit | Optional per-record audiences, key-bound credentials and sourced conventions use the same state contract. |
|
|
45
47
|
|
|
46
48
|
For example, a team fixes a logout bug by keeping sessions on the server. Months later, an agent proposes removing that code. Hunch can surface the original reason and rejected alternative before the edit. A supported, trusted rule can flag the conflict; strict mode can block it. Recording the lesson and configuring the integration are what make this possible.
|
|
47
49
|
|
|
@@ -124,13 +126,17 @@ This shares a project's engineering memory. The state server below adds authenti
|
|
|
124
126
|
|
|
125
127
|
A coding agent needs to know why a module exists. An operations agent may need to know whether a customer action was completed or who owes the next follow-up. Both need a maintained record they can check.
|
|
126
128
|
|
|
127
|
-
Hunch
|
|
129
|
+
Hunch ships `hunch serve`: a self-hosted HTTP service for organization, team, user, and repository records. A configured identity determines which scopes an agent may access. Optional record audiences further restrict access; optional key-bound credentials require proof from the configured private key on each request.
|
|
130
|
+
|
|
131
|
+
Open `/operator` on your server to inspect current records, completed work and commitments in a read-only browser view. Writer-supplied citations can point to an exact summary field or text passage and its recorded sources. They show traceability; they do not prove that a source supports a claim.
|
|
132
|
+
|
|
133
|
+
Agents can use the same contract through MCP, `hunch state read|write|records|subscribe`, the `@davesheffer/hunch/state` TypeScript client, or the [Python client](docs/python-state-client.md). The Python package is built and tested from this repository; it is not yet published to PyPI. [Scoped conventions](docs/scoped-conventions.md) let a person record sourced user, team or organization preferences. Those preferences remain advisory and do not silently become blocking rules.
|
|
128
134
|
|
|
129
135
|
Records can describe decisions, action outcomes, commitments, entities, relationships, and summaries that name their dependencies. Actions retain their status, including unknown or unverified outcomes. Repeated writes have stable identities, conflicting current decisions are refused, and confirmed human records receive protections against agent overwrites. These are defined checks on structured records; Hunch cannot establish every fact in the outside world on its own.
|
|
130
136
|
|
|
131
137
|
This is what **deterministic state** means here: explicit rules govern the stored record, rather than having each agent reconstruct it from scratch. Git holds the durable data; SQLite is a rebuildable index. The server binds to loopback and requires deployment and agent integration by its operator. Hunch does not provide a managed CRM or email connector service.
|
|
132
138
|
|
|
133
|
-
[Set up and understand the state server](docs/deterministic-state.md) · [State contract and client reference](docs/nuryel-state-contract.md)
|
|
139
|
+
[Set up and understand the state server](docs/deterministic-state.md) · [State contract and client reference](docs/nuryel-state-contract.md) · [Upgrade to 1.33](docs/upgrade-1.33.md)
|
|
134
140
|
|
|
135
141
|
### The vision, and what is still being tested
|
|
136
142
|
|
package/dist/cli/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
|
|
|
26
26
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
27
27
|
import { registerIntegrationCommands } from "./integrations.js";
|
|
28
28
|
import { registerTaskReportCommands } from "./taskReport.js";
|
|
29
|
+
import { registerStateCommands } from "./state.js";
|
|
29
30
|
import { registerServeCommands } from "./serve.js";
|
|
30
31
|
import { registerUpdateCommand } from "./update.js";
|
|
31
32
|
import { registerReviewMemoryCommands } from "./reviewMemory.js";
|
|
@@ -164,6 +165,7 @@ registerIntegrationCommands(program, () => {
|
|
|
164
165
|
});
|
|
165
166
|
registerTaskReportCommands(program, () => { const { store, root } = storeFor(); return { store, root }; });
|
|
166
167
|
registerServeCommands(program);
|
|
168
|
+
registerStateCommands(program);
|
|
167
169
|
registerUpdateCommand(program);
|
|
168
170
|
registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
|
|
169
171
|
const { store, root } = storeFor();
|
package/dist/cli/serve.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { proofPublicKey } from '../core/stateProof.js';
|
|
1
3
|
import { resolve } from "node:path";
|
|
2
4
|
import { createServeApp } from "../serve/app.js";
|
|
3
|
-
import { initServeConfig, partitionFor, readServeConfig } from "../serve/config.js";
|
|
5
|
+
import { initServeConfig, partitionFor, readServeConfig, writeServeConfig } from "../serve/config.js";
|
|
4
6
|
import { compactLedger } from "../store/changeLedger.js";
|
|
5
7
|
import { HunchStore } from "../store/hunchStore.js";
|
|
6
8
|
import { hunchPaths } from "../core/paths.js";
|
|
@@ -32,11 +34,23 @@ export function registerServeCommands(program) {
|
|
|
32
34
|
// reverse proxy that terminates TLS and auth of its own. Folded-in decision from Hunch Memory.
|
|
33
35
|
app.listen(port, "127.0.0.1", () => {
|
|
34
36
|
console.log(`hunch ${HUNCH_VERSION} serving nuryel.state/1 on http://127.0.0.1:${port} — ${config.partitions.map((p) => scopePath(p.scope)).join(", ")} (${config.principals.length} principal(s))`);
|
|
37
|
+
console.log(`Shared state view: http://127.0.0.1:${port}/operator`);
|
|
35
38
|
});
|
|
36
39
|
const stop = () => { app.close(() => { app.closeStores(); process.exit(0); }); };
|
|
37
40
|
process.on("SIGINT", stop);
|
|
38
41
|
process.on("SIGTERM", stop);
|
|
39
42
|
});
|
|
43
|
+
serve.command("revoke")
|
|
44
|
+
.description("Revoke a principal credential; current servers reload the config before each request")
|
|
45
|
+
.requiredOption("--principal <id>", "principal whose credential to revoke")
|
|
46
|
+
.action((opts) => {
|
|
47
|
+
const { file, ...config } = readServeConfig(resolve(serve.opts().config ?? DEFAULT_CONFIG));
|
|
48
|
+
if (!config.principals.some(p => p.id === opts.principal))
|
|
49
|
+
throw new Error('principal is not configured');
|
|
50
|
+
config.principals = config.principals.filter(p => p.id !== opts.principal);
|
|
51
|
+
writeServeConfig(file, config);
|
|
52
|
+
console.log(JSON.stringify({ revoked: opts.principal }));
|
|
53
|
+
});
|
|
40
54
|
serve.command("compact")
|
|
41
55
|
.description("Compact a served partition's change ledger: keep the newest N events, move the floor up; subscribers below the floor resynchronize")
|
|
42
56
|
.requiredOption("--partition <kind:id>", "the partition whose ledger to compact")
|
|
@@ -105,6 +119,8 @@ export function registerServeCommands(program) {
|
|
|
105
119
|
.requiredOption("--root <dir>", "directory whose .hunch/ holds the partition (created if missing)")
|
|
106
120
|
.option("--config <file>", `serve config to create or extend; default ${DEFAULT_CONFIG}`)
|
|
107
121
|
.option("--principal <id>", "principal to add or rotate, granted this partition")
|
|
122
|
+
.option("--proof-key-file <file>", "bind this token to an Ed25519 public PEM or JWK key")
|
|
123
|
+
.option("--public-origin <origin>", "external HTTPS origin of the reverse proxy; required for key-bound tokens")
|
|
108
124
|
.option("--kind <kind>", "principal kind: human | agent | service", "agent")
|
|
109
125
|
.option("--grant <kind:id...>", "additional partitions to grant the principal (must be served by this config)")
|
|
110
126
|
.option("--port <n>", "port to record in a new config")
|
|
@@ -120,10 +136,20 @@ export function registerServeCommands(program) {
|
|
|
120
136
|
const port = parent.port ?? opts.port;
|
|
121
137
|
const scope = parseScopeArg(opts.partition);
|
|
122
138
|
const grants = [scope, ...(opts.grant ?? []).map(parseScopeArg)];
|
|
139
|
+
let proofKey;
|
|
140
|
+
if (opts.proofKeyFile) {
|
|
141
|
+
if (!opts.principal)
|
|
142
|
+
throw new Error('--proof-key-file requires --principal');
|
|
143
|
+
const stat = statSync(opts.proofKeyFile);
|
|
144
|
+
if (!stat.isFile() || stat.size > 8192)
|
|
145
|
+
throw new Error('public proof key must be a regular file of at most 8 KiB');
|
|
146
|
+
proofKey = proofPublicKey(readFileSync(opts.proofKeyFile, 'utf8'));
|
|
147
|
+
}
|
|
123
148
|
const result = initServeConfig({
|
|
124
149
|
file: configFile, scope, root: resolve(opts.root),
|
|
125
|
-
...(opts.principal ? { principal: { id: opts.principal, kind: opts.kind, grants } } : {}),
|
|
150
|
+
...(opts.principal ? { principal: { id: opts.principal, kind: opts.kind, grants, proofKey } } : {}),
|
|
126
151
|
...(port ? { port: Number(port) } : {}),
|
|
152
|
+
...(opts.publicOrigin ? { publicOrigin: opts.publicOrigin } : {}),
|
|
127
153
|
});
|
|
128
154
|
if (opts.json) {
|
|
129
155
|
console.log(JSON.stringify({ config: configFile, partition: result.partition, token: result.token }));
|
|
@@ -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/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;
|