@davesheffer/hunch 1.32.8 → 1.35.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 (79) hide show
  1. package/README.md +18 -4
  2. package/dist/cli/index.js +52 -6
  3. package/dist/cli/invocation.d.ts +8 -0
  4. package/dist/cli/invocation.js +17 -10
  5. package/dist/cli/serve.js +28 -2
  6. package/dist/cli/state.d.ts +3 -0
  7. package/dist/cli/state.js +150 -0
  8. package/dist/cli/taskReport.js +52 -4
  9. package/dist/cli/update.js +5 -5
  10. package/dist/client/state.d.ts +86 -18
  11. package/dist/client/state.js +16 -2
  12. package/dist/client/stateProof.d.ts +4 -0
  13. package/dist/client/stateProof.js +17 -0
  14. package/dist/constitution/behaviorEvaluator.js +1 -1
  15. package/dist/constitution/schema.d.ts +14 -14
  16. package/dist/core/automaticReviewMemory.d.ts +5 -0
  17. package/dist/core/conventionDelivery.d.ts +8 -0
  18. package/dist/core/conventionDelivery.js +52 -0
  19. package/dist/core/fieldProvenance.d.ts +8 -0
  20. package/dist/core/fieldProvenance.js +72 -0
  21. package/dist/core/recordVisibility.d.ts +9 -0
  22. package/dist/core/recordVisibility.js +25 -0
  23. package/dist/core/stateCanonical.d.ts +3 -0
  24. package/dist/core/stateCanonical.js +34 -0
  25. package/dist/core/stateContract.d.ts +125 -10
  26. package/dist/core/stateContract.js +26 -31
  27. package/dist/core/stateDelivery.d.ts +3 -3
  28. package/dist/core/stateDelivery.js +10 -1
  29. package/dist/core/stateHttp.d.ts +280 -0
  30. package/dist/core/stateHttp.js +17 -0
  31. package/dist/core/stateProof.d.ts +13 -0
  32. package/dist/core/stateProof.js +34 -0
  33. package/dist/core/stateRecords.d.ts +127 -0
  34. package/dist/core/stateRecords.js +48 -0
  35. package/dist/core/taskRecord.d.ts +39 -0
  36. package/dist/core/taskRecord.js +185 -0
  37. package/dist/core/taskReport.d.ts +8 -1
  38. package/dist/core/taskReport.js +28 -14
  39. package/dist/core/taskReportEvidence.js +2 -1
  40. package/dist/core/taskReportHook.d.ts +18 -3
  41. package/dist/core/taskReportHook.js +77 -8
  42. package/dist/core/taskReportPaths.d.ts +6 -0
  43. package/dist/core/taskReportPaths.js +13 -0
  44. package/dist/core/types.d.ts +321 -4
  45. package/dist/core/types.js +47 -2
  46. package/dist/core/updatecheck.d.ts +51 -0
  47. package/dist/core/updatecheck.js +266 -0
  48. package/dist/core/version.d.ts +2 -0
  49. package/dist/core/version.js +3 -1
  50. package/dist/extractors/git.js +3 -10
  51. package/dist/integrations/gitignore.js +1 -0
  52. package/dist/integrations/health.js +27 -2
  53. package/dist/mcp/server.js +15 -5
  54. package/dist/mcp/taskReportTools.d.ts +4 -4
  55. package/dist/mcp/taskReportTools.js +32 -3
  56. package/dist/serve/app.d.ts +2 -0
  57. package/dist/serve/app.js +71 -30
  58. package/dist/serve/config.d.ts +16 -0
  59. package/dist/serve/config.js +27 -7
  60. package/dist/serve/operator.d.ts +4 -0
  61. package/dist/serve/operator.js +223 -0
  62. package/dist/serve/stateProof.d.ts +15 -0
  63. package/dist/serve/stateProof.js +105 -0
  64. package/dist/store/changeLedger.d.ts +6 -0
  65. package/dist/store/hunchStore.d.ts +9 -3
  66. package/dist/store/hunchStore.js +36 -19
  67. package/dist/store/stateAccess.d.ts +13 -0
  68. package/dist/store/stateAccess.js +85 -0
  69. package/dist/store/stateBinding.d.ts +13 -18
  70. package/dist/store/stateBinding.js +161 -52
  71. package/dist/store/stateCapture.js +10 -2
  72. package/dist/store/stateError.d.ts +12 -0
  73. package/dist/store/stateError.js +12 -0
  74. package/dist/store/statePartition.d.ts +9 -0
  75. package/dist/store/statePartition.js +30 -0
  76. package/dist/taskReports.d.ts +1 -1
  77. package/dist/taskReports.js +16 -4
  78. package/package.json +5 -1
  79. package/server.json +2 -2
package/README.md CHANGED
@@ -23,6 +23,9 @@ hunch init
23
23
  hunch backfill --since 90d # optional: draft memory from recent history
24
24
  ```
25
25
 
26
+ If the shell reports that `hunch` is not found, initialize without a global binary from the
27
+ repository directory: `npx -y @davesheffer/hunch@latest init`.
28
+
26
29
  Reload your assistant, then ask:
27
30
 
28
31
  > Why is this built this way, and what should I preserve when changing it?
@@ -41,7 +44,9 @@ Hunch works with Claude Code, Codex, Cursor, VS Code/Copilot, Windsurf, Antigrav
41
44
  | Check the rules your team chose | Evaluates supported constraints and code relationships without a model in the blocking path. |
42
45
  | 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
46
  | 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. |
47
+ | Share work state across agents | Serves authorized records through HTTP, MCP, the state CLI, and typed TypeScript and Python clients. |
48
+ | Inspect what the agents know | A read-only browser view shows current records, commitments, completed work and writer-supplied citations. |
49
+ | Keep access and conventions explicit | Optional per-record audiences, key-bound credentials and sourced conventions use the same state contract. |
45
50
 
46
51
  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
52
 
@@ -76,8 +81,13 @@ From each repository that uses Hunch:
76
81
  hunch update
77
82
  ```
78
83
 
84
+ If the shell reports that `hunch` is not found, run
85
+ `npx -y @davesheffer/hunch@latest update` from the repository instead.
86
+
79
87
  Or ask your agent to **“update Hunch.”** The command installs the latest release, aligns configured integration pins, repairs known legacy launch commands, and refreshes Hunch instructions. It preserves unrelated settings and intentionally disabled hooks.
80
88
 
89
+ Installed interactive CLI commands can also show a cached update notice. At most once every 24 hours, a detached worker asks npm for the package's public `latest` version; hooks, MCP, CI, servers, the updater, non-interactive commands, and source checkouts skip that request. Set `HUNCH_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1` to disable it.
90
+
81
91
  - A standalone npm project keeps Hunch in its existing dependency section at an exact version. Without a repository dependency, the global CLI is updated. Add `--global` to update both.
82
92
  - For other package managers or workspaces, update the dependency with that package manager, then run `hunch integrations repair-pins`.
83
93
  - Restart or reconnect active assistants. In Codex, open `/hooks` to review and trust changed commands, then start a new session. A changed version pin changes the command and requires renewed trust.
@@ -124,13 +134,17 @@ This shares a project's engineering memory. The state server below adds authenti
124
134
 
125
135
  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
136
 
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.
137
+ 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.
138
+
139
+ 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.
140
+
141
+ 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
142
 
129
143
  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
144
 
131
145
  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
146
 
133
- [Set up and understand the state server](docs/deterministic-state.md) · [State contract and client reference](docs/nuryel-state-contract.md)
147
+ [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
148
 
135
149
  ### The vision, and what is still being tested
136
150
 
@@ -172,7 +186,7 @@ Profiles retain their revision, sources, confidence, and freshness. [Project DNA
172
186
  - [Review memory](docs/review-memory.md)
173
187
  - [Agent-origin handling](docs/agent-origin.md)
174
188
  - [Autonomy ladder](docs/autonomy-ladder.md)
175
- - [Autonomous development](docs/autonomous-development.md)
189
+ - [Autonomous development](https://github.com/davesheffer/hunch-private/blob/main/projects/hunch/docs/autonomous-development.md)
176
190
  - [Changelog](CHANGELOG.md) · [Roadmap](ROADMAP.md)
177
191
  - [VS Code extension](vscode-extension/README.md)
178
192
  - [Architecture benchmark](bench/architectural-conformance.md)
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";
@@ -84,7 +85,7 @@ import { recordServed, servedSummary } from "../core/served.js";
84
85
  import { recordTaskDelivery, reportActivity, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
85
86
  import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
86
87
  import { renderRecalledLine } from "../core/taskReportRender.js";
87
- import { hookReportTaskId, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
88
+ import { hookReportTaskId, nativeHookCwd, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
88
89
  import { recordHookObservation } from "../core/hookObservations.js";
89
90
  import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
90
91
  import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, unverifiedNag, } from "../core/pipeline.js";
@@ -130,6 +131,7 @@ import { ENTITY_KINDS } from "../core/types.js";
130
131
  import { planCompaction } from "../store/compact.js";
131
132
  import { repairDecisionReference } from "../core/refrepair.js";
132
133
  import { resolveInvocation, dim, synthesisStatusLines, maybeWarnOllamaContext } from "./invocation.js";
134
+ import { formatUpdateNotice, scheduleUpdateCheck, shouldCheckForUpdate } from "../core/updatecheck.js";
133
135
  const program = new Command();
134
136
  program.name("hunch").description("Hunch — engineering memory and a deterministic Change Gate for AI-assisted codebases.").version(HUNCH_VERSION);
135
137
  program.option("--initiator <name>", "bind agent launches to the originating CLI (Claude, Codex, Kimi, or a configured adapter)")
@@ -164,6 +166,7 @@ registerIntegrationCommands(program, () => {
164
166
  });
165
167
  registerTaskReportCommands(program, () => { const { store, root } = storeFor(); return { store, root }; });
166
168
  registerServeCommands(program);
169
+ registerStateCommands(program);
167
170
  registerUpdateCommand(program);
168
171
  registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
169
172
  const { store, root } = storeFor();
@@ -198,6 +201,29 @@ registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
198
201
  return { root, existing: store.captureHome(privateOnly) === "private"
199
202
  ? store.recs("constraints") : store.json.loadAll("constraints") };
200
203
  });
204
+ // Read an already-known update and schedule any registry refresh in a detached
205
+ // worker. No network handle is opened in this command's process, so the
206
+ // advisory cannot delay command completion or process exit.
207
+ program.hook("preAction", (_thisCommand, actionCommand) => {
208
+ try {
209
+ const path = [actionCommand.name()];
210
+ for (let parent = actionCommand.parent; parent && parent !== program; parent = parent.parent)
211
+ path.unshift(parent.name());
212
+ const gate = {
213
+ commandName: path.join(" "),
214
+ isTTY: process.stderr.isTTY === true,
215
+ installed: resolveInvocation().installed,
216
+ };
217
+ if (!shouldCheckForUpdate(gate))
218
+ return;
219
+ const result = scheduleUpdateCheck();
220
+ if (result)
221
+ console.error(dim(formatUpdateNotice(result)));
222
+ }
223
+ catch {
224
+ // Never let the update-check advisory abort the command it's piggybacking on.
225
+ }
226
+ });
201
227
  let openStore = null;
202
228
  function openTeamStore(root, opts = {}) {
203
229
  // A committed team.json is an explicit declaration that this checkout belongs
@@ -4191,7 +4217,7 @@ program
4191
4217
  try {
4192
4218
  const records = asOf ? [] : snapshotDeliveredRecords(store, envelope);
4193
4219
  const recalled = renderRecalledLine(unseenLessons(root, opts.task, records));
4194
- const occurrence = recordTaskDelivery(root, opts.task, envelope, records);
4220
+ const occurrence = recordTaskDelivery(root, opts.task, envelope, records, undefined, target);
4195
4221
  console.log(`\n${recalled ? `${recalled}\n` : ""}Task evidence: ${opts.task} · occurrence ${occurrence}`);
4196
4222
  }
4197
4223
  catch {
@@ -4580,6 +4606,12 @@ program
4580
4606
  // explorers get the indexed shape, planners get live decisions + what
4581
4607
  // was already rejected, everyone else gets the invariant digest. Public
4582
4608
  // store only; cheap reads.
4609
+ const routedCwd = nativeHookCwd(root, provider, evt);
4610
+ // A native host that supplied cwd made an explicit scope claim. If it is
4611
+ // malformed or names another checkout, serving this process root's memory
4612
+ // would cross worktrees; stay silent instead of guessing which side is right.
4613
+ if ((provider === "claude" || provider === "codex") && evt.cwd !== undefined && !routedCwd)
4614
+ return;
4583
4615
  const s = new HunchStore(paths);
4584
4616
  try {
4585
4617
  const clip1 = (text, max) => {
@@ -4589,11 +4621,19 @@ program
4589
4621
  const type = (evt.agent_type ?? "").toLowerCase();
4590
4622
  const L = [];
4591
4623
  const served = [];
4624
+ const route = routedCwd
4625
+ ? `Worktree routing: call hunch_context first with cwd: ${JSON.stringify(routedCwd)}, and pass the same cwd to hunch_task, hunch_report, and every Hunch capture/write call in this delegated task.`
4626
+ : null;
4627
+ const activeProvider = provider;
4628
+ const emitRouteOnly = () => { if (route)
4629
+ emitContext(activeProvider, "SubagentStart", route); };
4592
4630
  if (/explore|search|investigat/.test(type)) {
4593
4631
  // Orient from the graph, not grep rounds: the component map IS the shape.
4594
4632
  const components = s.advisoryRecs("components").filter((c) => c.status === "active");
4595
- if (!components.length)
4633
+ if (!components.length) {
4634
+ emitRouteOnly();
4596
4635
  return;
4636
+ }
4597
4637
  L.push(`🧠 Hunch — repo shape for a delegated explorer: ${components.length} component(s).`);
4598
4638
  for (const c of components.slice(0, 12)) {
4599
4639
  const line = `- ${c.name}${c.paths.length ? ` (${c.paths.slice(0, 2).join(", ")})` : ""}${c.responsibility ? ` — ${clip1(c.responsibility, 90)}` : ""}`;
@@ -4609,8 +4649,10 @@ program
4609
4649
  const decisions = s.advisoryRecs("decisions")
4610
4650
  .filter((d) => d.status === "accepted")
4611
4651
  .sort((a, b) => (a.date < b.date ? 1 : -1));
4612
- if (!decisions.length)
4652
+ if (!decisions.length) {
4653
+ emitRouteOnly();
4613
4654
  return;
4655
+ }
4614
4656
  L.push(`🧠 Hunch — live decisions for a delegated planner (${decisions.length} in force; plans must not re-propose the rejected).`);
4615
4657
  for (const d of decisions.slice(0, 6)) {
4616
4658
  const line = `- ${d.title} (${d.id})${d.alternatives_rejected.length ? ` — rejected: ${clip1(d.alternatives_rejected[0], 80)}` : ""}`;
@@ -4624,8 +4666,10 @@ program
4624
4666
  const constraints = s.advisoryRecs("constraints")
4625
4667
  .filter((c) => c.status === "active")
4626
4668
  .sort((a, b) => sevRank[a.severity] - sevRank[b.severity]);
4627
- if (!constraints.length)
4669
+ if (!constraints.length) {
4670
+ emitRouteOnly();
4628
4671
  return;
4672
+ }
4629
4673
  L.push(`🧠 Hunch — delegated agent grounding: ${constraints.length} invariant(s) in force in this repo.`);
4630
4674
  for (const c of constraints.slice(0, 8)) {
4631
4675
  const line = `- [${c.severity}] ${clip1(c.statement, 140)}${c.scope.length ? ` (scope: ${c.scope.slice(0, 3).join(", ")})` : ""}`;
@@ -4636,6 +4680,8 @@ program
4636
4680
  L.push(`…and ${constraints.length - 8} more — hunch_check_constraints(scope) for your files.`);
4637
4681
  L.push("Before editing: hunch_check_constraints(scope) · hunch_why(target). Orient: hunch_context(task).");
4638
4682
  }
4683
+ if (route)
4684
+ L.push(route);
4639
4685
  // No dedup here: the hook event carries the PARENT session id, but each
4640
4686
  // spawned agent is a fresh empty context — deduping would ground the
4641
4687
  // first Explore and silently starve every later one.
@@ -4898,7 +4944,7 @@ program
4898
4944
  // The first time a lesson reaches this prompt's task, tell the USER in one
4899
4945
  // line (systemMessage); repeats of the same revision stay silent.
4900
4946
  recalled = reportPresentationEnabled(root) ? renderRecalledLine(unseenLessons(root, reportTaskId, snapshots)) : null;
4901
- const occurrence = recordTaskDelivery(root, reportTaskId, envelope, snapshots);
4947
+ const occurrence = recordTaskDelivery(root, reportTaskId, envelope, snapshots, undefined, target);
4902
4948
  reportNotice = `\n\nHunch task ${reportTaskId} · delivery ${occurrence}. Inspect exact application references with hunch_report(task_id).`;
4903
4949
  }
4904
4950
  catch {
@@ -7,6 +7,9 @@ export interface ResolvedInvocation {
7
7
  agentHookShell: string;
8
8
  /** Structured command/args for .mcp.json (subcommand appended by the writer). */
9
9
  mcp: Invocation;
10
+ /** True only when running from an installed/published copy (global, local,
11
+ * or npx cache), never from a source checkout. */
12
+ installed: boolean;
10
13
  }
11
14
  export declare function dim(s: string): string;
12
15
  /** Portable invocation written into committed MCP/provider configuration.
@@ -32,4 +35,9 @@ export declare function synthesisStatusLines(resolution: ProviderResolution, env
32
35
  * separate from synthesisStatusLines (sync, already fully covered) because
33
36
  * this one makes a best-effort network call. */
34
37
  export declare function maybeWarnOllamaContext(providerName: string, env: NodeJS.ProcessEnv): Promise<string | null>;
38
+ /** Classify the entry path without faking import.meta.url in tests. */
39
+ export declare function classifyEntry(entry: string): {
40
+ isDev: boolean;
41
+ installed: boolean;
42
+ };
35
43
  export declare function resolveInvocation(): ResolvedInvocation;
@@ -77,27 +77,32 @@ export async function maybeWarnOllamaContext(providerName, env) {
77
77
  return null;
78
78
  return probeOllamaNumCtx(env.HUNCH_SYNTH_BASE_URL ?? "", env.HUNCH_SYNTH_MODEL ?? "");
79
79
  }
80
+ /** Classify the entry path without faking import.meta.url in tests. */
81
+ export function classifyEntry(entry) {
82
+ const isDev = entry.endsWith(".ts");
83
+ const installed = !isDev && entry.replace(/\\/g, "/").includes("/node_modules/");
84
+ return { isDev, installed };
85
+ }
80
86
  export function resolveInvocation() {
81
87
  const entry = fileURLToPath(import.meta.url).replace(/invocation\.(js|ts)$/, "index.$1");
82
- const isDev = entry.endsWith(".ts");
88
+ const { isDev, installed } = classifyEntry(entry);
83
89
  // JSON.stringify yields a double-quoted, backslash-escaped token /bin/sh
84
90
  // accepts — so install paths with spaces don't break the hook command.
85
91
  const q = (s) => JSON.stringify(s);
86
- // Running from an installed copy (global, local, or npx cache i.e. NOT a
87
- // source checkout we're hacking on). The MCP/provider config files we write
88
- // are committed and shared across a team via git, so they must NOT embed this
89
- // machine's absolute path or OS-specific separators. Reference the exact
90
- // published Hunch package instead, which `npx` resolves the same on any OS
91
- // and any clone without floating to a newer release. The git hook lives in
92
- // per-machine .git/hooks (never committed), so it keeps the PATH-robust
93
- // absolute-node invocation below.
94
- const installed = !isDev && entry.replace(/\\/g, "/").includes("/node_modules/");
92
+ // The MCP/provider config files we write are committed and shared across a
93
+ // team via git, so they must NOT embed this machine's absolute path or
94
+ // OS-specific separators. Reference the exact published Hunch package
95
+ // instead, which `npx` resolves the same on any OS and any clone without
96
+ // floating to a newer release. The git hook lives in per-machine
97
+ // .git/hooks (never committed), so it keeps the PATH-robust absolute-node
98
+ // invocation below.
95
99
  if (installed) {
96
100
  const mcp = publishedMcpInvocation();
97
101
  return {
98
102
  shell: `${q(process.execPath)} ${q(entry)}`,
99
103
  agentHookShell: shellInvocation(mcp),
100
104
  mcp,
105
+ installed,
101
106
  };
102
107
  }
103
108
  if (isDev) {
@@ -106,6 +111,7 @@ export function resolveInvocation() {
106
111
  shell: `npx tsx ${q(entry)}`,
107
112
  agentHookShell: shellInvocation(mcp),
108
113
  mcp,
114
+ installed,
109
115
  };
110
116
  }
111
117
  // Source-checkout dist run (e.g. `node dist/cli/index.js`, npm link): inherently
@@ -116,6 +122,7 @@ export function resolveInvocation() {
116
122
  shell: `${q(process.execPath)} ${q(entry)}`,
117
123
  agentHookShell: shellInvocation(mcp),
118
124
  mcp,
125
+ installed,
119
126
  };
120
127
  }
121
128
  //# sourceMappingURL=invocation.js.map
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
@@ -7,6 +7,7 @@ import { DEFAULT_CHECK_TIMEOUT_MS, MAX_CHECK_TIMEOUT_MS, reportSourceSnapshot, r
7
7
  import { renderTaskReport, writeTaskReportHtml } from "../core/taskReportRender.js";
8
8
  import { assertReportPath } from "../core/taskReportPaths.js";
9
9
  import { publicTaskReport } from "../core/taskReportPublic.js";
10
+ import { mergeDurableTaskSummaries, persistTaskRecord } from "../core/taskRecord.js";
10
11
  export function registerTaskReportCommands(program, openStore) {
11
12
  const task = program.command("task").description("Record an explicit task lifecycle for Hunch contribution reports");
12
13
  task.command("start <title>").option("--id <id>", "retry an exact existing task identity")
@@ -39,14 +40,44 @@ export function registerTaskReportCommands(program, openStore) {
39
40
  catch { /* disclosed as unverified */ }
40
41
  }
41
42
  finishReportTask(root, id, opts.interrupted ? "interrupted" : "completed");
42
- console.log(renderTaskReport(readTaskReport(root, id, reportSourceSnapshot(root).hash)));
43
+ // The finished task becomes graph memory (.hunch/tasks/) through the normal
44
+ // capture path. A failed write is disclosed, never a reason to lose the card.
45
+ let graph = "";
46
+ try {
47
+ const opened = openStore();
48
+ try {
49
+ const saved = persistTaskRecord(opened.root, opened.store, id);
50
+ graph = saved
51
+ ? `\nGraph ${saved.changed ? "saved" : "already saved"} as ${saved.record.id} (${saved.home}${saved.flushed ? `, ${saved.flushed}` : ""})`
52
+ : "\nGraph nothing to keep (no observation, or task records disabled)";
53
+ }
54
+ finally {
55
+ opened.store.close();
56
+ }
57
+ }
58
+ catch (error) {
59
+ graph = `\nGraph not saved: ${error.message}`;
60
+ }
61
+ console.log(renderTaskReport(readTaskReport(root, id, reportSourceSnapshot(root).hash)) + graph);
43
62
  });
44
63
  task.command("list").description("Recent tasks observed in this repository with what Hunch delivered, saved, guarded, and checked")
45
64
  .option("--limit <n>", "how many recent tasks (max 30)", "30")
46
65
  .option("--json", "machine-readable summaries (consumed by the VS Code Contribution view)")
47
66
  .action((opts) => {
48
67
  const root = findRoot();
49
- const summaries = listTaskSummaries(root, Number(opts.limit) || 30, reportSourceSnapshot(root).hash);
68
+ const limit = Number(opts.limit) || 30;
69
+ let summaries = listTaskSummaries(root, limit, reportSourceSnapshot(root).hash);
70
+ // Graph records (this machine's or a teammate's) join the local ledger view.
71
+ try {
72
+ const opened = openStore();
73
+ try {
74
+ summaries = mergeDurableTaskSummaries(opened.store, summaries, limit);
75
+ }
76
+ finally {
77
+ opened.store.close();
78
+ }
79
+ }
80
+ catch { /* ledger-only view when the store is unavailable */ }
50
81
  if (opts.json) {
51
82
  console.log(JSON.stringify(summaries, null, 2));
52
83
  return;
@@ -56,7 +87,7 @@ export function registerTaskReportCommands(program, openStore) {
56
87
  return;
57
88
  }
58
89
  for (const s of summaries)
59
- console.log(`${s.task.started_at.slice(0, 16).replace("T", " ")} ${s.task.task_id} ${s.task.state.padEnd(11)} ${renderTaskStatusLine(s) || "nothing observed"}`);
90
+ console.log(`${s.task.started_at.slice(0, 16).replace("T", " ")} ${s.task.task_id} ${s.task.state.padEnd(11)} ${renderTaskStatusLine(s) || "nothing observed"}${s.durable ? ` [graph: ${s.durable.home}]` : ""}`);
60
91
  });
61
92
  task.command("stats").description("Adherence over a window: how many prompts Hunch reached (delivery), checked, saved, or guarded — from the ledger, never from agent claims")
62
93
  .option("--days <days>", "window in days", "7")
@@ -188,7 +219,24 @@ export function registerTaskReportCommands(program, openStore) {
188
219
  console.log(JSON.stringify(publicTaskReport(root, id), null, 2));
189
220
  return;
190
221
  }
191
- const report = readTaskReport(root, id, reportSourceSnapshot(root).hash);
222
+ let report;
223
+ try {
224
+ report = readTaskReport(root, id, reportSourceSnapshot(root).hash);
225
+ }
226
+ catch (error) {
227
+ // Not in this machine's ledger: the graph record (if any) is what remains.
228
+ const opened = openStore();
229
+ try {
230
+ const record = opened.store.getRec("tasks", id);
231
+ if (!record)
232
+ throw error;
233
+ console.log(opts.json ? JSON.stringify(record, null, 2) : `Task ${record.id} · ${record.state} · ${record.title}\nGraph record only (no local observation ledger for it here): ${record.lessons.length} lesson(s), ${record.applied.length} applied, ${record.saved.length} saved, ${record.checks.length} check(s), ${record.refusals} denied. Files: ${record.files.join(", ") || "none recorded"}.`);
234
+ return;
235
+ }
236
+ finally {
237
+ opened.store.close();
238
+ }
239
+ }
192
240
  console.log(opts.json ? JSON.stringify(report, null, 2) : renderTaskReport(report));
193
241
  });
194
242
  }