@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
@@ -0,0 +1,266 @@
1
+ /** Best-effort update notices for interactive CLI commands.
2
+ *
3
+ * The foreground process only reads a small cache and starts an unreferenced,
4
+ * detached worker when that cache is stale. Network I/O happens in the worker,
5
+ * so an unavailable registry cannot hold the user's command open. The first
6
+ * cold invocation populates the cache; later invocations can display it.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { basename, dirname, isAbsolute, join, resolve, win32 } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { randomUUID } from "node:crypto";
14
+ import { HUNCH_PACKAGE_NAME, HUNCH_VERSION } from "./version.js";
15
+ const REGISTRY_URL = `https://registry.npmjs.org/${HUNCH_PACKAGE_NAME}/latest`;
16
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
17
+ const FETCH_TIMEOUT_MS = 1500;
18
+ const WORKER_FLAG = "--hunch-refresh-update-cache";
19
+ const MAX_CACHE_BYTES = 4096;
20
+ function configuredCacheRoot(value, platform) {
21
+ if (!value)
22
+ return null;
23
+ const absolute = platform === "win32" ? win32.isAbsolute(value) : isAbsolute(value);
24
+ const containsMarker = value.replace(/\\/g, "/").split("/").some(part => {
25
+ // Win32 aliases path components with trailing spaces or periods to the
26
+ // unadorned name, so `.hunch.` and `.hunch ` can address `.hunch` too.
27
+ const normalized = platform === "win32" ? part.replace(/[ .]+$/g, "") : part;
28
+ return normalized.toLowerCase() === ".hunch";
29
+ });
30
+ if (!absolute || containsMarker)
31
+ return null;
32
+ return value;
33
+ }
34
+ /** Use each platform's normal per-user cache root. This deliberately never
35
+ * creates a `.hunch` path segment: `.hunch` is the repository marker used by
36
+ * findRoot(), so placing a cache there could redirect commands to the wrong
37
+ * project. */
38
+ export function defaultCacheFile(opts = {}) {
39
+ const env = opts.env ?? process.env;
40
+ const home = opts.home ?? homedir();
41
+ const platform = opts.platform ?? process.platform;
42
+ const cacheHome = configuredCacheRoot(env.XDG_CACHE_HOME, platform)
43
+ || (platform === "win32" && configuredCacheRoot(env.LOCALAPPDATA, platform))
44
+ || (platform === "darwin" ? join(home, "Library", "Caches") : join(home, ".cache"));
45
+ return join(cacheHome, "hunch", "update-check.json");
46
+ }
47
+ function readCache(file) {
48
+ try {
49
+ const stat = lstatSync(file);
50
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_CACHE_BYTES)
51
+ return null;
52
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
53
+ if (!Number.isFinite(parsed.lastCheckedAt) || (parsed.lastCheckedAt ?? -1) < 0)
54
+ return null;
55
+ if (parsed.latestSeen !== undefined && parseVersion(parsed.latestSeen) === null)
56
+ return null;
57
+ return { lastCheckedAt: parsed.lastCheckedAt, ...(parsed.latestSeen ? { latestSeen: parsed.latestSeen } : {}) };
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /** Cache writes are atomic even though this cache is derived and disposable:
64
+ * concurrent CLI processes must not leave malformed JSON that causes every
65
+ * subsequent command to schedule another network request. */
66
+ function writeCache(file, cache) {
67
+ const temp = join(dirname(file), `.${basename(file)}.${process.pid}.${randomUUID()}.tmp`);
68
+ try {
69
+ mkdirSync(dirname(file), { recursive: true });
70
+ writeFileSync(temp, `${JSON.stringify(cache)}\n`, { mode: 0o600 });
71
+ renameSync(temp, file);
72
+ return true;
73
+ }
74
+ catch {
75
+ try {
76
+ unlinkSync(temp);
77
+ }
78
+ catch { /* best-effort cleanup */ }
79
+ return false;
80
+ }
81
+ }
82
+ /** Strict SemVer parsing keeps untrusted registry text out of terminal output. */
83
+ function parseVersion(version) {
84
+ if (version.length > 256)
85
+ return null;
86
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version);
87
+ if (!match)
88
+ return null;
89
+ const prerelease = match[4]?.split(".") ?? null;
90
+ if (prerelease?.some(part => /^\d+$/.test(part) && part.length > 1 && part.startsWith("0")))
91
+ return null;
92
+ return {
93
+ core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])],
94
+ prerelease,
95
+ };
96
+ }
97
+ /** Full SemVer precedence for the release and prerelease fields. */
98
+ export function isNewerVersion(candidate, current) {
99
+ const a = parseVersion(candidate);
100
+ const b = parseVersion(current);
101
+ if (!a || !b)
102
+ return false;
103
+ for (let i = 0; i < a.core.length; i++) {
104
+ if (a.core[i] !== b.core[i])
105
+ return a.core[i] > b.core[i];
106
+ }
107
+ if (a.prerelease === null || b.prerelease === null)
108
+ return a.prerelease === null && b.prerelease !== null;
109
+ for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
110
+ const left = a.prerelease[i];
111
+ const right = b.prerelease[i];
112
+ if (left === undefined || right === undefined)
113
+ return right === undefined;
114
+ if (left === right)
115
+ continue;
116
+ const leftNumeric = /^\d+$/.test(left);
117
+ const rightNumeric = /^\d+$/.test(right);
118
+ if (leftNumeric && rightNumeric)
119
+ return BigInt(left) > BigInt(right);
120
+ if (leftNumeric !== rightNumeric)
121
+ return !leftNumeric;
122
+ return left > right;
123
+ }
124
+ return false;
125
+ }
126
+ async function fetchLatestVersion(fetchImpl) {
127
+ try {
128
+ const response = await fetchImpl(REGISTRY_URL, {
129
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
130
+ redirect: "error",
131
+ });
132
+ if (!response.ok)
133
+ return null;
134
+ const body = (await response.json());
135
+ return typeof body.version === "string" && parseVersion(body.version) ? body.version : null;
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ }
141
+ /** Worker entry point, exported so network/cache behavior is testable without
142
+ * spawning a process or contacting the real registry. */
143
+ export async function refreshUpdateCache(opts = {}) {
144
+ const cacheFile = opts.cacheFile ?? defaultCacheFile();
145
+ const latest = await fetchLatestVersion(opts.fetchImpl ?? fetch);
146
+ if (latest === null)
147
+ return false;
148
+ return writeCache(cacheFile, { lastCheckedAt: (opts.now ?? Date.now)(), latestSeen: latest });
149
+ }
150
+ function cacheIsFresh(cache, now) {
151
+ return cache !== null && cache.lastCheckedAt <= now && now - cache.lastCheckedAt < CHECK_INTERVAL_MS;
152
+ }
153
+ /** Serialize the short cache claim across simultaneous CLI processes. This
154
+ * advisory fails closed when a prior process left the lock behind: reclaiming
155
+ * a pathname without an OS lock cannot distinguish that stale file from a new
156
+ * owner's lock on both POSIX and Windows. */
157
+ function acquireRefreshLock(cacheFile, now) {
158
+ const lockFile = `${cacheFile}.lock`;
159
+ try {
160
+ mkdirSync(dirname(cacheFile), { recursive: true });
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ try {
166
+ writeFileSync(lockFile, `${now}\n`, { flag: "wx", mode: 0o600 });
167
+ return lockFile;
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ /** Read any known update immediately and schedule at most one refresh per day.
174
+ * Claiming the interval before spawning also bounds offline requests: failed
175
+ * checks are not retried on every CLI command. */
176
+ export function scheduleUpdateCheck(opts = {}) {
177
+ try {
178
+ const cacheFile = opts.cacheFile ?? defaultCacheFile();
179
+ const currentVersion = opts.currentVersion ?? HUNCH_VERSION;
180
+ const now = (opts.now ?? Date.now)();
181
+ const cache = readCache(cacheFile);
182
+ const notice = cache?.latestSeen && isNewerVersion(cache.latestSeen, currentVersion)
183
+ ? { current: currentVersion, latest: cache.latestSeen }
184
+ : null;
185
+ if (cacheIsFresh(cache, now))
186
+ return notice;
187
+ const lockFile = acquireRefreshLock(cacheFile, now);
188
+ if (!lockFile)
189
+ return notice;
190
+ let claimed = false;
191
+ try {
192
+ // Another process may have refreshed while this one was acquiring the
193
+ // claim. Re-read under the lock before deciding to schedule a worker.
194
+ const current = readCache(cacheFile);
195
+ if (cacheIsFresh(current, now))
196
+ return notice;
197
+ // If the cache cannot record the claim, skip the request. Otherwise a
198
+ // read-only/misconfigured cache directory would trigger a request forever.
199
+ claimed = writeCache(cacheFile, { lastCheckedAt: now, ...(current?.latestSeen ? { latestSeen: current.latestSeen } : {}) });
200
+ }
201
+ finally {
202
+ try {
203
+ unlinkSync(lockFile);
204
+ }
205
+ catch { /* derived lock cleanup is best effort */ }
206
+ }
207
+ if (!claimed)
208
+ return notice;
209
+ const workerFile = opts.workerFile ?? fileURLToPath(import.meta.url);
210
+ try {
211
+ const child = (opts.spawnImpl ?? spawn)(process.execPath, [workerFile, WORKER_FLAG, cacheFile], {
212
+ detached: true,
213
+ stdio: "ignore",
214
+ windowsHide: true,
215
+ });
216
+ child.once?.("error", () => { });
217
+ child.unref();
218
+ }
219
+ catch { /* the cached notice remains useful even when refresh cannot start */ }
220
+ return notice;
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ }
226
+ /** Commands used by hooks, servers, CI, or the updater itself must never start
227
+ * an advisory worker, even when they inherit a terminal. */
228
+ const PLUMBING_COMMANDS = new Set([
229
+ "mcp",
230
+ "check",
231
+ "merge-driver",
232
+ "merge-driver-grounding",
233
+ "sync",
234
+ "repair-provenance",
235
+ "hook",
236
+ "ci",
237
+ "serve",
238
+ "update",
239
+ ]);
240
+ export function shouldCheckForUpdate({ commandName, isTTY, installed, env = process.env }) {
241
+ if (PLUMBING_COMMANDS.has(commandName) || /^(?:task|integrations|serve)\s/.test(commandName) || !isTTY || !installed)
242
+ return false;
243
+ return !env.CI && !env.HUNCH_NO_UPDATE_CHECK && !env.NO_UPDATE_NOTIFIER;
244
+ }
245
+ export function formatUpdateNotice(result) {
246
+ return (`A newer Hunch version is available: ${result.current} -> ${result.latest}\n` +
247
+ "Run `hunch update` in the repository (`hunch update --global` if this CLI is global). " +
248
+ "Set HUNCH_NO_UPDATE_CHECK=1 to stop checking.");
249
+ }
250
+ function isWorkerInvocation() {
251
+ if (process.argv[2] !== WORKER_FLAG || !process.argv[1])
252
+ return false;
253
+ try {
254
+ return resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
255
+ }
256
+ catch {
257
+ return false;
258
+ }
259
+ }
260
+ if (isWorkerInvocation()) {
261
+ // This process is detached from the user's command. All errors are local to
262
+ // the derived cache and intentionally produce neither output nor a nonzero
263
+ // exit that could be mistaken for the command's result.
264
+ void refreshUpdateCache({ cacheFile: process.argv[3] }).catch(() => { });
265
+ }
266
+ //# sourceMappingURL=updatecheck.js.map
@@ -1,4 +1,6 @@
1
1
  export declare const HUNCH_VERSION: string;
2
+ /** Shared identity for Hunch's published package. */
3
+ export declare const HUNCH_PACKAGE_NAME = "@davesheffer/hunch";
2
4
  /** Exact public npm package consumed by generated CI and shared MCP/provider
3
5
  * configs. A floating package name would let one committed configuration run
4
6
  * different Hunch semantics as npm's latest release changes. */
@@ -16,10 +16,12 @@ export const HUNCH_VERSION = (() => {
16
16
  return "0.0.0";
17
17
  }
18
18
  })();
19
+ /** Shared identity for Hunch's published package. */
20
+ export const HUNCH_PACKAGE_NAME = "@davesheffer/hunch";
19
21
  /** Exact public npm package consumed by generated CI and shared MCP/provider
20
22
  * configs. A floating package name would let one committed configuration run
21
23
  * different Hunch semantics as npm's latest release changes. */
22
- export const HUNCH_PACKAGE_SPEC = `@davesheffer/hunch@${HUNCH_VERSION}`;
24
+ export const HUNCH_PACKAGE_SPEC = `${HUNCH_PACKAGE_NAME}@${HUNCH_VERSION}`;
23
25
  /** npm alias used by npx launchers. Giving the fetched package a distinct local
24
26
  * alias prevents npm exec from treating this repository (which has the same
25
27
  * package name) as satisfying the request and then falling through to an older
@@ -1838,16 +1838,9 @@ export function isLinkedWorktree(cwd) {
1838
1838
  const own = gitSafe(["rev-parse", "--absolute-git-dir"], cwd);
1839
1839
  if (!common || !own)
1840
1840
  return false;
1841
- // realpath BOTH before comparing: `--absolute-git-dir` is symlink-resolved while
1842
- // gitCommonDir is not, so on macOS the main checkout would otherwise mismatch on
1843
- // /var vs /private/var and falsely read as "linked".
1844
- const norm = (p) => { try {
1845
- return realpathSync(p);
1846
- }
1847
- catch {
1848
- return resolve(p);
1849
- } };
1850
- return norm(own) !== norm(common);
1841
+ // Git can spell the same directory differently: /var vs /private/var on
1842
+ // macOS, or long vs 8.3/case variants on Windows. Compare physical identity.
1843
+ return !sameFilesystemEntry(own, common);
1851
1844
  }
1852
1845
  /** Current branch name (e.g. "main", "feat/x"), or "" in detached HEAD / non-repo.
1853
1846
  * Stamped onto auto-captured decisions so branch-scoped work stays filterable. */
@@ -61,6 +61,7 @@ const MEM_ENTRIES = [
61
61
  ".hunch/edges/",
62
62
  ".hunch/runbooks/",
63
63
  ".hunch/findings/",
64
+ ".hunch/tasks/",
64
65
  // nuryel.state/1 record kinds (state facets)
65
66
  ".hunch/receipts/",
66
67
  ".hunch/commitments/",
@@ -164,14 +164,39 @@ export function inspectIntegrations(root, selected) {
164
164
  continue;
165
165
  const capabilities = Object.fromEntries(CAPABILITIES.map(c => [c, { status: "untested", detail: "No runtime evidence" }]));
166
166
  report.harnesses.push({ harness, capabilities });
167
+ const mcpPath = join(root, spec.mcp);
168
+ const hooksFileExists = Boolean(spec.hooks && existsSync(join(root, spec.hooks)));
169
+ let mcpEntryAbsent = false;
170
+ try {
171
+ lstatSync(mcpPath);
172
+ }
173
+ catch (e) {
174
+ mcpEntryAbsent = e.code === "ENOENT";
175
+ }
167
176
  try {
168
177
  const launcher = readLauncher(root, harness);
169
178
  recordPins(spec.mcp, [launcher.command, ...launcher.args]);
170
179
  capabilities.mcp.detail = "Configured locally; use --probe to verify a fresh server, then reconnect the host";
171
180
  }
172
181
  catch (e) {
173
- report.issues.push({ file: spec.mcp, code: "mcp-config", detail: e.message });
174
- capabilities.mcp.detail = "MCP configuration missing, disabled, invalid, or outside supported inspection format";
182
+ // A harness can be detected here via its hooks file alone —
183
+ // some hooks files are deliberately committed while their MCP config is
184
+ // a per-clone, gitignored scaffold (e.g. this repo's own
185
+ // .windsurf/hooks.json). On a fresh checkout that config simply doesn't
186
+ // exist yet, which is a "not configured on this machine" state, not a
187
+ // repository-level misconfiguration — it must stay `untested`
188
+ // (informational, matching every other not-yet-evidenced capability
189
+ // here), not a hard `issues` entry that fails `hunch doctor` on every
190
+ // clone forever. A file that EXISTS but is malformed/disabled/
191
+ // unreadable in some other way is still a genuine issue.
192
+ // lstat distinguishes a truly absent per-machine file from a dangling
193
+ // symlink. The latter is a broken configuration and must remain loud.
194
+ const notConfiguredHere = !selected && hooksFileExists && mcpEntryAbsent && e.code === "ENOENT";
195
+ if (!notConfiguredHere)
196
+ report.issues.push({ file: spec.mcp, code: "mcp-config", detail: e.message });
197
+ capabilities.mcp.detail = notConfiguredHere
198
+ ? "Not configured on this machine — a hooks file exists, but no local MCP config exists yet; run `hunch init` or set up this host"
199
+ : "MCP configuration disabled, invalid, or outside supported inspection format";
175
200
  }
176
201
  let events = {};
177
202
  let disabled = false;
@@ -1,3 +1,5 @@
1
+ import { conventionSupplements } from '../core/conventionDelivery.js';
2
+ import { fieldCitationText } from "../core/fieldProvenance.js";
1
3
  /**
2
4
  * MCP server — the structured two-way API into the Hunch (DESIGN.md §7 / App. A).
3
5
  * Exposes read tools (query/why/bug_lineage/check_constraints/get_dependents) and
@@ -843,6 +845,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
843
845
  parts.push(`\nCOMPONENTS: ${w.components.map((c) => `${c.name} (${c.id})`).join(", ")}`);
844
846
  if (w.symbols.length)
845
847
  parts.push(`\nSYMBOLS: ${w.symbols.slice(0, WHY_CAP * 2).map((s) => `${s.name} [fan-in ${s.metrics.fan_in}, churn ${s.metrics.churn_90d}]`).join(", ")}${more(w.symbols.length, WHY_CAP * 2)}`);
848
+ const recentTasks = store.tasksFor(target, 5);
849
+ if (recentTasks.length) {
850
+ parts.push(`\nRECENT TASKS (agent work that touched this):\n${recentTasks.map((t) => ` • ${t.id} ${t.finished_at.slice(0, 10)} ${t.title} — ${t.lessons.length} lesson(s), ${t.applied.length} applied, ${t.saved.length} saved${t.conformance.some((c) => c.outcome === "violated") ? ", rule VIOLATED" : ""}`).join("\n")}`);
851
+ }
846
852
  if (parts.length === 1)
847
853
  parts.push("\n(No recorded decisions/bugs/constraints yet for this target.)");
848
854
  return ok(parts.join("\n"));
@@ -1069,7 +1075,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1069
1075
  const records = as_of ? [] : snapshotDeliveredRecords(store, envelope);
1070
1076
  // First delivery of a revision in this task earns one line; repeats stay quiet.
1071
1077
  const recalled = renderRecalledLine(unseenLessons(root, task_id, records));
1072
- const occurrence = recordTaskDelivery(root, task_id, envelope, records);
1078
+ const occurrence = recordTaskDelivery(root, task_id, envelope, records, undefined, target);
1073
1079
  result.content.push({ type: "text", text: `${recalled ? `${recalled}\n` : ""}Task evidence: ${task_id} · occurrence ${occurrence}.\n${records.slice(0, 20).map(r => `${r.record_id} @ ${r.content_hash}`).join("\n")}${records.length > 20 ? "\nMore record identities: hunch_report(task_id)." : ""}` });
1074
1080
  }
1075
1081
  catch {
@@ -1102,7 +1108,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1102
1108
  decisionCorpus: store.recs("decisions"),
1103
1109
  historical: !!asOf,
1104
1110
  profile: profile ?? "builder",
1105
- supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding],
1111
+ supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding, ...(asOf ? [] : conventionSupplements(store.recs("conventions")))],
1106
1112
  };
1107
1113
  // Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
1108
1114
  // used to return an empty brief while the graph held the answer — fall back to
@@ -1822,7 +1828,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1822
1828
  const r = (response.records ?? {})[ref.id] ?? {};
1823
1829
  const g = (k) => { const v = r[k]; return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v); };
1824
1830
  if (ref.facet === "derived")
1825
- return `- ${label} derived ${ref.id} · computed ${g("computed_at")} · ${r.dependencies?.length ?? 0} dependencies\n ${g("content").slice(0, 1200)}`;
1831
+ return `- ${label} derived ${ref.id} · computed ${g("computed_at")} · ${r.dependencies?.length ?? 0} dependencies\n ${g("content").slice(0, 1200)}${fieldCitationText(r)}`;
1826
1832
  if (ref.facet === "commitments")
1827
1833
  return `- ${label} commitment ${ref.id} · ${g("status")} · due ${g("due")} · owner ${g("owner")}: ${g("title")}${r.closed_by ? ` · closed by ${g("closed_by")}` : ""}`;
1828
1834
  if (ref.facet === "receipts") {
@@ -1858,7 +1864,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1858
1864
  : sor.observed_truncated ? ['- More observations exist; read this subject with observed_page:{} in one partition, then follow next_cursor.'] : []),
1859
1865
  ...(sor.invalidated_by.length ? [`- invalidated by: ${sor.invalidated_by.join(", ")}`] : [])].join("\n") || "(nothing on record for this subject)"
1860
1866
  : "";
1861
- return stateResult(`${response.receipt_id} · ${summary}${deniedNote}${stateText ? `\n\nState of record:\n${stateText}` : ""}\n\n${envelope.text}`, response);
1867
+ const conventionText = response.conventions ? '\n\nExplicit conventions (advisory; no scope takes precedence):\n' + response.conventions.items.map(item => {
1868
+ const record = response.records?.[item.ref.id];
1869
+ return `- ${item.ref.scope.kind}/${item.ref.scope.id} · ${item.key} · ${record?.status}/${item.currentness}${item.conflict ? ' · CONFLICT' : ''}: ${String(record?.value ?? '').slice(0, 300)} (${item.ref.id})`;
1870
+ }).join('\n') + (response.conventions.truncated ? '\nMore conventions exist; this view is incomplete.' : '') : '';
1871
+ return stateResult(`${response.receipt_id} · ${summary}${deniedNote}${stateText ? `\n\nState of record:\n${stateText}` : ""}\n\n${envelope.text}${conventionText}`, response);
1862
1872
  }
1863
1873
  catch (e) {
1864
1874
  return stateRefusal(e);
@@ -1866,7 +1876,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1866
1876
  });
1867
1877
  server.registerTool("nuryel_write", {
1868
1878
  title: "nuryel.state/1 write — provenance + idempotency in, durability out",
1869
- description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version.",
1879
+ description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version. With capability nuryel.field-provenance/1, a derived record may carry field_provenance: [{selector:{kind:json_pointer,path:/field} or {kind:text,start:0,end:10}, value_hash:stateHash(selected scalar or text), dependency_hashes:[stateHash(existing dependency)]}]. Text offsets count Unicode code points, end exclusive. Citations are writer-supplied traceability, not verified support; negotiate support in every shared reader before writing them. With nuryel.record-visibility/1, records may carry visibility:{owner:principal-id,readers:[ids],writers:[ids]}; the owner is implicit in both lists, and writers must be readers. Owner-only audience changes require expected_version, including supersession. Restricted records require a dedicated partition; local stdio principal assertions assume trusted callers.",
1870
1880
  inputSchema: { ...WriteRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
1871
1881
  outputSchema: WriteResultSchema.shape,
1872
1882
  }, async ({ cwd: _cwd, ...input }) => {
@@ -17,7 +17,7 @@ export declare function boundedTaskReport(report: ReturnType<typeof readTaskRepo
17
17
  finished_at: string | null;
18
18
  state: "open" | "completed" | "interrupted";
19
19
  };
20
- coverage: "delivered" | "no-delivery-observed" | "no-relevant-memory";
20
+ coverage: "no-delivery-observed" | "no-relevant-memory" | "delivered";
21
21
  content_hash: string;
22
22
  unknowns: string[];
23
23
  deliveries: {
@@ -73,7 +73,7 @@ export declare function boundedTaskReport(report: ReturnType<typeof readTaskRepo
73
73
  title: string;
74
74
  home: "public" | "private";
75
75
  operation: "updated" | "created";
76
- durability: "committed" | "pushed" | "local";
76
+ durability: "local" | "committed" | "pushed";
77
77
  }[];
78
78
  refusals: ({
79
79
  source: "native-edit-gate";
@@ -106,7 +106,7 @@ export declare function boundedTaskReportForHost(report: ReturnType<typeof readT
106
106
  finished_at: string | null;
107
107
  state: "open" | "completed" | "interrupted";
108
108
  };
109
- coverage: "delivered" | "no-delivery-observed" | "no-relevant-memory";
109
+ coverage: "no-delivery-observed" | "no-relevant-memory" | "delivered";
110
110
  content_hash: string;
111
111
  unknowns: string[];
112
112
  deliveries: {
@@ -162,7 +162,7 @@ export declare function boundedTaskReportForHost(report: ReturnType<typeof readT
162
162
  title: string;
163
163
  home: "public" | "private";
164
164
  operation: "updated" | "created";
165
- durability: "committed" | "pushed" | "local";
165
+ durability: "local" | "committed" | "pushed";
166
166
  }[];
167
167
  refusals: ({
168
168
  source: "native-edit-gate";
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { TaskIdSchema, ReportClaimSchema, LessonReferenceSchema, finishReportTask, listReportTasks, readTaskReport, readLessonHistory, recordReportClaim, reportPresentationEnabled, startReportTask } from "../core/taskReport.js";
4
+ import { persistTaskRecord } from "../core/taskRecord.js";
4
5
  import { reportSourceSnapshot, runReportConformance } from "../core/taskReportEvidence.js";
5
6
  import { renderTaskReport, writeTaskReportHtml } from "../core/taskReportRender.js";
6
7
  function applicationReferences(report) {
@@ -73,7 +74,18 @@ export function registerTaskReportTools(server, getRoot, getStore) {
73
74
  if (action === "start") {
74
75
  if (!title || applications?.length || outcome)
75
76
  throw new Error("start requires a short task title and no completion evidence");
76
- const task = startReportTask(root, title, task_id);
77
+ let task;
78
+ try {
79
+ task = startReportTask(root, title, task_id);
80
+ }
81
+ catch (error) {
82
+ // A native prompt task already exists under this exact ID (opened by the
83
+ // host hook). Its persisted title is authoritative; a paraphrased title
84
+ // from the model must not fork a second report or fail the start.
85
+ if (!task_id || !/different title/.test(error.message))
86
+ throw error;
87
+ task = readTaskReport(root, task_id, reportSourceSnapshot(root).hash).task;
88
+ }
77
89
  const launcher = verificationLauncher();
78
90
  return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]. The default budget is 2 minutes; add --timeout <seconds> before -- for a long suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
79
91
  }
@@ -90,6 +102,23 @@ export function registerTaskReportTools(server, getRoot, getStore) {
90
102
  catch { /* unknowns disclose it */ }
91
103
  }
92
104
  finishReportTask(root, task_id, outcome ?? "completed");
105
+ // The finished task becomes graph memory through the normal capture path;
106
+ // a failed write is disclosed on the card, never a reason to lose it.
107
+ let graph = null;
108
+ let graphNote = "";
109
+ try {
110
+ const saved = persistTaskRecord(root, getStore(), task_id);
111
+ if (saved) {
112
+ graph = { id: saved.record.id, home: saved.home, flushed: saved.flushed, changed: saved.changed };
113
+ graphNote = `\nGraph ${saved.changed ? "saved" : "already saved"} as ${saved.record.id} (${saved.home}${saved.flushed ? `, ${saved.flushed}` : ""})`;
114
+ }
115
+ else {
116
+ graphNote = "\nGraph nothing to keep (no observation, or task records disabled)";
117
+ }
118
+ }
119
+ catch (error) {
120
+ graphNote = `\nGraph not saved: ${error.message}`;
121
+ }
93
122
  const report = readTaskReport(root, task_id, reportSourceSnapshot(root).hash);
94
123
  const show = reportPresentationEnabled(root);
95
124
  let file = null;
@@ -97,8 +126,8 @@ export function registerTaskReportTools(server, getRoot, getStore) {
97
126
  file = writeTaskReportHtml(root, task_id);
98
127
  }
99
128
  catch { /* retained report remains inspectable through MCP */ }
100
- const card = file ? renderTaskReport(report).replace(/^Evidence .*$/m, `Evidence [Open local report](<${file}>)`) : renderTaskReport(report);
101
- return { content: [{ type: "text", text: show ? card : "Task report retained. Automatic presentation is disabled; omit the contribution card from the final response." }], structuredContent: { ...boundedTaskReportForHost(report), presentation_enabled: show, contribution_card: show ? card : null, report_path: file } };
129
+ const card = (file ? renderTaskReport(report).replace(/^Evidence .*$/m, `Evidence [Open local report](<${file}>)`) : renderTaskReport(report)) + graphNote;
130
+ return { content: [{ type: "text", text: show ? card : "Task report retained. Automatic presentation is disabled; omit the contribution card from the final response." }], structuredContent: { ...boundedTaskReportForHost(report), presentation_enabled: show, contribution_card: show ? card : null, report_path: file, graph_record: graph } };
102
131
  }
103
132
  catch (error) {
104
133
  const message = `Task report unavailable: ${error.message}`;
@@ -27,6 +27,8 @@ export declare class HttpProblem extends Error {
27
27
  constructor(status: number, code: string, message: string, extra?: Record<string, unknown>);
28
28
  }
29
29
  export interface ServeOptions {
30
+ /** Required for key-bound credentials when supplying an in-memory configuration. */
31
+ authStateDir?: string;
30
32
  version?: string;
31
33
  /** Injectable for tests: how a partition's store is opened. */
32
34
  openStore?: (root: string) => HunchStore;