@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
|
@@ -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
|
package/dist/core/version.d.ts
CHANGED
|
@@ -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. */
|
package/dist/core/version.js
CHANGED
|
@@ -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 =
|
|
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
|
package/dist/extractors/git.js
CHANGED
|
@@ -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
|
-
//
|
|
1842
|
-
//
|
|
1843
|
-
|
|
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. */
|
|
@@ -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
|
-
|
|
174
|
-
|
|
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;
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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}`;
|
package/dist/serve/app.d.ts
CHANGED
|
@@ -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;
|