@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.
- package/README.md +18 -4
- package/dist/cli/index.js +52 -6
- package/dist/cli/invocation.d.ts +8 -0
- package/dist/cli/invocation.js +17 -10
- package/dist/cli/serve.js +28 -2
- package/dist/cli/state.d.ts +3 -0
- package/dist/cli/state.js +150 -0
- package/dist/cli/taskReport.js +52 -4
- package/dist/cli/update.js +5 -5
- package/dist/client/state.d.ts +86 -18
- 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 +14 -14
- 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 +125 -10
- 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/taskRecord.d.ts +39 -0
- package/dist/core/taskRecord.js +185 -0
- package/dist/core/taskReport.d.ts +8 -1
- package/dist/core/taskReport.js +28 -14
- package/dist/core/taskReportEvidence.js +2 -1
- package/dist/core/taskReportHook.d.ts +18 -3
- package/dist/core/taskReportHook.js +77 -8
- package/dist/core/taskReportPaths.d.ts +6 -0
- package/dist/core/taskReportPaths.js +13 -0
- package/dist/core/types.d.ts +321 -4
- package/dist/core/types.js +47 -2
- package/dist/core/updatecheck.d.ts +51 -0
- package/dist/core/updatecheck.js +266 -0
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.js +3 -1
- package/dist/extractors/git.js +3 -10
- package/dist/integrations/gitignore.js +1 -0
- package/dist/integrations/health.js +27 -2
- package/dist/mcp/server.js +15 -5
- package/dist/mcp/taskReportTools.d.ts +4 -4
- package/dist/mcp/taskReportTools.js +32 -3
- 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 +9 -3
- package/dist/store/hunchStore.js +36 -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/dist/taskReports.d.ts +1 -1
- package/dist/taskReports.js +16 -4
- package/package.json +5 -1
- 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
|
|
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
|
|
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 {
|
package/dist/cli/invocation.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/invocation.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
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,150 @@
|
|
|
1
|
+
import { createStateProofSigner } from '../client/stateProof.js';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { createStateClient, StateClientError } from '../client/state.js';
|
|
5
|
+
import { ReadRequestSchema, WriteRequestSchema, SubscribeRequestSchema, RecordsRequestSchema, ScopeSchema, STATE_FIELD_PROVENANCE_VERSION, STATE_RECORD_VISIBILITY_VERSION } from '../core/stateContract.js';
|
|
6
|
+
const MAX_INPUT_BYTES = 1024 * 1024;
|
|
7
|
+
function scopeFrom(value) {
|
|
8
|
+
const match = /^([a-z]+):(.+)$/.exec(value ?? '');
|
|
9
|
+
if (!match)
|
|
10
|
+
throw new Error('pass --scope kind:id, or supply scope in --input JSON');
|
|
11
|
+
return ScopeSchema.parse({ kind: match[1], id: match[2] });
|
|
12
|
+
}
|
|
13
|
+
async function inputObject(file) {
|
|
14
|
+
let text;
|
|
15
|
+
if (file !== '-') {
|
|
16
|
+
const stat = statSync(file);
|
|
17
|
+
if (!stat.isFile() || stat.size > MAX_INPUT_BYTES)
|
|
18
|
+
throw new Error('input must be a regular JSON file of at most 1 MiB');
|
|
19
|
+
text = readFileSync(file, 'utf8');
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
if (process.stdin.isTTY)
|
|
23
|
+
throw new Error('pipe a JSON request or pass --input <file>');
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let length = 0;
|
|
26
|
+
for await (const chunk of process.stdin) {
|
|
27
|
+
const bytes = Buffer.from(chunk);
|
|
28
|
+
length += bytes.length;
|
|
29
|
+
if (length > MAX_INPUT_BYTES)
|
|
30
|
+
throw new Error('input exceeds 1 MiB');
|
|
31
|
+
chunks.push(bytes);
|
|
32
|
+
}
|
|
33
|
+
text = Buffer.concat(chunks).toString('utf8');
|
|
34
|
+
}
|
|
35
|
+
if (Buffer.byteLength(text) > MAX_INPUT_BYTES)
|
|
36
|
+
throw new Error('input exceeds 1 MiB');
|
|
37
|
+
let value;
|
|
38
|
+
try {
|
|
39
|
+
value = JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error('input must be valid JSON');
|
|
43
|
+
}
|
|
44
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
45
|
+
throw new Error('input must be a JSON request object');
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function connection(options) {
|
|
49
|
+
const timeoutMs = Number(options.timeout);
|
|
50
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000)
|
|
51
|
+
throw new Error('--timeout must be 1..300000 milliseconds');
|
|
52
|
+
const url = new URL(options.url);
|
|
53
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
|
|
54
|
+
throw new Error('--url must be an HTTP(S) base URL without credentials, query or fragment');
|
|
55
|
+
let token = process.env.HUNCH_STATE_TOKEN?.trim();
|
|
56
|
+
if (options.tokenFile) {
|
|
57
|
+
const stat = statSync(options.tokenFile);
|
|
58
|
+
if (!stat.isFile() || stat.size > 8192)
|
|
59
|
+
throw new Error('token file must be a regular file of at most 8 KiB');
|
|
60
|
+
token = readFileSync(options.tokenFile, 'utf8').trim();
|
|
61
|
+
}
|
|
62
|
+
if (!token)
|
|
63
|
+
throw new Error('set HUNCH_STATE_TOKEN or pass --token-file; tokens are not accepted as command arguments');
|
|
64
|
+
let proof;
|
|
65
|
+
if (options.proofKeyFile) {
|
|
66
|
+
const stat = statSync(options.proofKeyFile);
|
|
67
|
+
if (!stat.isFile() || stat.size > 8192)
|
|
68
|
+
throw new Error('proof key must be a regular private-key file of at most 8 KiB');
|
|
69
|
+
proof = createStateProofSigner(readFileSync(options.proofKeyFile, 'utf8'));
|
|
70
|
+
}
|
|
71
|
+
return createStateClient({ baseUrl: options.url, token, timeoutMs, proof });
|
|
72
|
+
}
|
|
73
|
+
export function registerStateCommands(program) {
|
|
74
|
+
const state = program.command('state').description('Read and write a served workspace using the state contract; JSON output')
|
|
75
|
+
.option('--url <url>', 'server base URL (or HUNCH_STATE_URL)', process.env.HUNCH_STATE_URL || 'http://127.0.0.1:7474')
|
|
76
|
+
.option('--token-file <file>', 'read a bearer token from a file; otherwise use HUNCH_STATE_TOKEN')
|
|
77
|
+
.option('--proof-key-file <file>', 'Ed25519 private PEM or JWK file for a key-bound token')
|
|
78
|
+
.option('--timeout <ms>', 'timeout for each HTTP request', '15000')
|
|
79
|
+
.option('--pretty', 'indent JSON output');
|
|
80
|
+
const run = (work) => async () => {
|
|
81
|
+
try {
|
|
82
|
+
const options = state.opts(), result = await work(connection(options));
|
|
83
|
+
process.stdout.write(JSON.stringify(result, null, options.pretty ? 2 : undefined) + '\n');
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const problem = error instanceof StateClientError ? error.problem : {
|
|
87
|
+
type: 'about:blank', title: error instanceof z.ZodError ? 'malformed' : 'client-error', status: 0,
|
|
88
|
+
detail: error instanceof z.ZodError ? error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ') : error instanceof Error ? error.message : 'state request failed',
|
|
89
|
+
};
|
|
90
|
+
process.stderr.write(JSON.stringify(problem) + '\n');
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
state.command('capabilities').description('Show the authenticated principal and supported contract capabilities')
|
|
95
|
+
.option('--scope <kind:id>', 'partition to negotiate; defaults to the token’s first grant')
|
|
96
|
+
.action((options) => run(client => client.capabilities(options.scope ? scopeFrom(options.scope) : undefined))());
|
|
97
|
+
for (const verb of ['read', 'write', 'records', 'subscribe']) {
|
|
98
|
+
const command = state.command(verb).description(verb === 'subscribe' ? 'Poll changes once; use head_seq as the next after_seq and honor resync' : `${verb} using the authenticated state contract`)
|
|
99
|
+
.option('--input <file>', 'complete request JSON; - reads stdin (default for write)');
|
|
100
|
+
if (verb !== 'write')
|
|
101
|
+
command.option('--scope <kind:id>', 'partition when using shortcut options');
|
|
102
|
+
if (verb === 'read')
|
|
103
|
+
command.option('--subject <subject>', 'exact subject or record key').option('--task <text>', 'task phrase for memory delivery');
|
|
104
|
+
if (verb === 'records')
|
|
105
|
+
command.option('--ids <id...>', 'exact record IDs');
|
|
106
|
+
if (verb === 'subscribe')
|
|
107
|
+
command.option('--after <seq>', 'last observed sequence; defaults to zero');
|
|
108
|
+
command.action((options) => run(async (client) => {
|
|
109
|
+
const shortcut = options.scope || options.subject || options.task || options.ids || options.after;
|
|
110
|
+
if (options.input && shortcut)
|
|
111
|
+
throw new Error('use either --input JSON or shortcut options, not both');
|
|
112
|
+
const raw = options.input || verb === 'write' ? await inputObject(options.input ?? '-') : {
|
|
113
|
+
scope: scopeFrom(options.scope),
|
|
114
|
+
...(options.subject !== undefined ? { subject: options.subject } : {}),
|
|
115
|
+
...(options.task !== undefined ? { task: options.task } : {}),
|
|
116
|
+
...(verb === 'records' ? { ids: options.ids } : {}),
|
|
117
|
+
...(verb === 'subscribe' ? { after_seq: Number(options.after ?? 0) } : {}),
|
|
118
|
+
};
|
|
119
|
+
const schemas = {
|
|
120
|
+
read: ReadRequestSchema.omit({ schema: true, principal: true }),
|
|
121
|
+
write: WriteRequestSchema.omit({ schema: true, principal: true }),
|
|
122
|
+
records: RecordsRequestSchema.omit({ schema: true, principal: true }),
|
|
123
|
+
subscribe: SubscribeRequestSchema.omit({ schema: true, principal: true }),
|
|
124
|
+
};
|
|
125
|
+
const request = schemas[verb].parse(raw);
|
|
126
|
+
const caps = await client.capabilities(request.scope);
|
|
127
|
+
const required = [`nuryel.state.${verb}/1`];
|
|
128
|
+
if ('record' in request) {
|
|
129
|
+
if (request.record.field_provenance !== undefined)
|
|
130
|
+
required.push(STATE_FIELD_PROVENANCE_VERSION);
|
|
131
|
+
if (request.record.visibility !== undefined)
|
|
132
|
+
required.push(STATE_RECORD_VISIBILITY_VERSION);
|
|
133
|
+
if (typeof request.record.schema === 'string')
|
|
134
|
+
required.push(request.record.schema);
|
|
135
|
+
}
|
|
136
|
+
const missing = required.filter(capability => !caps.capabilities.includes(capability));
|
|
137
|
+
if (missing.length)
|
|
138
|
+
throw new StateClientError(400, 'unsupported', { type: 'about:blank', title: 'unsupported', status: 400, detail: 'server lacks required capabilities: ' + missing.join(', ') });
|
|
139
|
+
// The chosen request schema matches the verb; state semantics remain server-owned.
|
|
140
|
+
if (verb === 'read')
|
|
141
|
+
return client.read(request);
|
|
142
|
+
if (verb === 'write')
|
|
143
|
+
return client.write(request);
|
|
144
|
+
if (verb === 'records')
|
|
145
|
+
return client.records(request);
|
|
146
|
+
return client.subscribe(request);
|
|
147
|
+
})());
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=state.js.map
|
package/dist/cli/taskReport.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|