@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.
Files changed (56) hide show
  1. package/README.md +9 -3
  2. package/dist/cli/index.js +2 -0
  3. package/dist/cli/serve.js +28 -2
  4. package/dist/cli/state.d.ts +3 -0
  5. package/dist/cli/state.js +150 -0
  6. package/dist/client/state.d.ts +82 -14
  7. package/dist/client/state.js +16 -2
  8. package/dist/client/stateProof.d.ts +4 -0
  9. package/dist/client/stateProof.js +17 -0
  10. package/dist/constitution/behaviorEvaluator.js +1 -1
  11. package/dist/constitution/schema.d.ts +2 -2
  12. package/dist/core/automaticReviewMemory.d.ts +5 -0
  13. package/dist/core/conventionDelivery.d.ts +8 -0
  14. package/dist/core/conventionDelivery.js +52 -0
  15. package/dist/core/fieldProvenance.d.ts +8 -0
  16. package/dist/core/fieldProvenance.js +72 -0
  17. package/dist/core/recordVisibility.d.ts +9 -0
  18. package/dist/core/recordVisibility.js +25 -0
  19. package/dist/core/stateCanonical.d.ts +3 -0
  20. package/dist/core/stateCanonical.js +34 -0
  21. package/dist/core/stateContract.d.ts +122 -7
  22. package/dist/core/stateContract.js +26 -31
  23. package/dist/core/stateDelivery.d.ts +3 -3
  24. package/dist/core/stateDelivery.js +10 -1
  25. package/dist/core/stateHttp.d.ts +280 -0
  26. package/dist/core/stateHttp.js +17 -0
  27. package/dist/core/stateProof.d.ts +13 -0
  28. package/dist/core/stateProof.js +34 -0
  29. package/dist/core/stateRecords.d.ts +127 -0
  30. package/dist/core/stateRecords.js +48 -0
  31. package/dist/core/types.d.ts +146 -4
  32. package/dist/core/types.js +8 -2
  33. package/dist/extractors/git.js +3 -10
  34. package/dist/mcp/server.js +10 -4
  35. package/dist/serve/app.d.ts +2 -0
  36. package/dist/serve/app.js +71 -30
  37. package/dist/serve/config.d.ts +16 -0
  38. package/dist/serve/config.js +27 -7
  39. package/dist/serve/operator.d.ts +4 -0
  40. package/dist/serve/operator.js +223 -0
  41. package/dist/serve/stateProof.d.ts +15 -0
  42. package/dist/serve/stateProof.js +105 -0
  43. package/dist/store/changeLedger.d.ts +6 -0
  44. package/dist/store/hunchStore.d.ts +4 -2
  45. package/dist/store/hunchStore.js +18 -19
  46. package/dist/store/stateAccess.d.ts +13 -0
  47. package/dist/store/stateAccess.js +85 -0
  48. package/dist/store/stateBinding.d.ts +13 -18
  49. package/dist/store/stateBinding.js +161 -52
  50. package/dist/store/stateCapture.js +10 -2
  51. package/dist/store/stateError.d.ts +12 -0
  52. package/dist/store/stateError.js +12 -0
  53. package/dist/store/statePartition.d.ts +9 -0
  54. package/dist/store/statePartition.js +30 -0
  55. package/package.json +5 -1
  56. 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 for decisions, actions, commitments, entities, and relationships through HTTP, MCP, and a typed client. |
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 already 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. The same state contract is available through MCP and the `@davesheffer/hunch/state` client.
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,3 @@
1
+ /** JSON CLI over the existing HTTP client. The server owns authorization and state rules. */
2
+ import type { Command } from 'commander';
3
+ export declare function registerStateCommands(program: Command): void;
@@ -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
@@ -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, ChangeEvent, Scope } from "../core/stateContract.js";
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 interface ClientSubscribeResponse {
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<ClientSubscribeResponse>;
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
  }>;
@@ -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 response = await doFetch(`${base}${path}`, {
23
+ const url = `${base}${path}`;
24
+ const request = async () => doFetch(url, {
23
25
  method,
24
- headers: { authorization: `Bearer ${opts.token}`, ...(body !== undefined ? { "content-type": "application/json" } : {}) },
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 since compilation re-plan and re-prove the policy (rb_g2_stale_policy_01)`);
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;