@davesheffer/hunch 1.38.0 → 1.39.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/dist/cli/index.js +255 -9
- package/dist/cli/serve.js +1 -0
- package/dist/client/readOrCompute.d.ts +77 -0
- package/dist/client/readOrCompute.js +85 -0
- package/dist/client/state.d.ts +1 -0
- package/dist/client/state.js +1 -0
- package/dist/constitution/g2.d.ts +1 -0
- package/dist/constitution/service.js +8 -0
- package/dist/constitution/sourceMutation.js +23 -18
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +13 -0
- package/dist/core/machine.d.ts +20 -0
- package/dist/core/machine.js +101 -0
- package/dist/core/taskRecord.js +6 -3
- package/dist/core/taskReport.d.ts +25 -2
- package/dist/core/taskReport.js +92 -18
- package/dist/core/taskReportEvidence.d.ts +4 -1
- package/dist/core/taskReportEvidence.js +5 -2
- package/dist/core/taskReportHook.d.ts +16 -3
- package/dist/core/taskReportHook.js +65 -13
- package/dist/core/taskTouched.d.ts +4 -0
- package/dist/core/taskTouched.js +30 -10
- package/dist/core/types.d.ts +66 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +234 -0
- package/dist/core/workspace.js +335 -0
- package/dist/extractors/helm.d.ts +17 -28
- package/dist/extractors/helm.js +12 -12
- package/dist/extractors/indexer.js +171 -7
- package/dist/extractors/k8sManifest.d.ts +59 -0
- package/dist/extractors/k8sManifest.js +507 -0
- package/dist/extractors/workspaces.d.ts +18 -0
- package/dist/extractors/workspaces.js +350 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/hooks.d.ts +2 -0
- package/dist/integrations/hooks.js +25 -0
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +73 -0
- package/dist/integrations/workspaceLedger.js +201 -0
- package/dist/mcp/server.js +54 -0
- package/dist/mcp/taskReportTools.d.ts +9 -0
- package/dist/mcp/taskReportTools.js +13 -5
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +107 -92
- package/dist/serve/mcpHttp.d.ts +27 -0
- package/dist/serve/mcpHttp.js +95 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -93,16 +93,21 @@ function parsedSymbolFor(graphSymbol, parsed) {
|
|
|
93
93
|
const base = symbolId(graphSymbol.file, graphSymbol.name, graphSymbol.kind);
|
|
94
94
|
return matches.find((_symbol, index) => (index === 0 ? base : `${base}_${index}`) === graphSymbol.id) ?? null;
|
|
95
95
|
}
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
/** `start`/`end` are JS string (UTF-16 code unit) indices. Despite their
|
|
97
|
+
* names, parse.ts's startByte/endByte/atByte carry the same units — native
|
|
98
|
+
* tree-sitter indexes the JS string it was handed, not its UTF-8 encoding —
|
|
99
|
+
* so every scan and splice against them must be string-based, never Buffer-
|
|
100
|
+
* based. */
|
|
101
|
+
function spliceChars(source, replacements) {
|
|
102
|
+
let result = source;
|
|
98
103
|
for (const replacement of [...replacements].sort((a, b) => b.start - a.start)) {
|
|
99
|
-
|
|
100
|
-
bytes.subarray(0, replacement.start),
|
|
101
|
-
Buffer.from(replacement.text, "utf8"),
|
|
102
|
-
bytes.subarray(replacement.end),
|
|
103
|
-
]);
|
|
104
|
+
result = result.slice(0, replacement.start) + replacement.text + result.slice(replacement.end);
|
|
104
105
|
}
|
|
105
|
-
return
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
/** Where a new top-level statement (an import) can be inserted without splitting a shebang line. */
|
|
109
|
+
function insertionPoint(source) {
|
|
110
|
+
return source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
|
|
106
111
|
}
|
|
107
112
|
function mutateSource(policy, base, sourceFile, source) {
|
|
108
113
|
const assertion = policy.assertion;
|
|
@@ -145,10 +150,10 @@ function mutateSource(policy, base, sourceFile, source) {
|
|
|
145
150
|
const specifier = relativeSpecifier(sourceFile, targetFile);
|
|
146
151
|
if (parsed.imports.some((candidate) => candidate === specifier))
|
|
147
152
|
return { error: "mutation-component-import-already-present" };
|
|
148
|
-
const insertion =
|
|
153
|
+
const insertion = insertionPoint(source);
|
|
149
154
|
return {
|
|
150
155
|
file: sourceFile,
|
|
151
|
-
source:
|
|
156
|
+
source: spliceChars(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(specifier)}; // hunch deterministic component mutation\n` }]),
|
|
152
157
|
};
|
|
153
158
|
}
|
|
154
159
|
const subject = symbolForSelector(base, assertion.subject);
|
|
@@ -161,7 +166,7 @@ function mutateSource(policy, base, sourceFile, source) {
|
|
|
161
166
|
if (!definition)
|
|
162
167
|
return { error: "mutation-subject-definition-unresolved" };
|
|
163
168
|
if (assertion.kind === "exists") {
|
|
164
|
-
return { file: subject.file, source:
|
|
169
|
+
return { file: subject.file, source: spliceChars(source, [{ start: definition.startByte, end: definition.endByte, text: "" }]) };
|
|
165
170
|
}
|
|
166
171
|
if (assertion.kind === "not-reaches"
|
|
167
172
|
&& assertion.relation.edges.length === 1
|
|
@@ -173,10 +178,10 @@ function mutateSource(policy, base, sourceFile, source) {
|
|
|
173
178
|
if (parsed.imports.some((specifier) => externalPackage(specifier) === dependency)) {
|
|
174
179
|
return { error: "mutation-forbidden-import-already-present" };
|
|
175
180
|
}
|
|
176
|
-
const insertion =
|
|
181
|
+
const insertion = insertionPoint(source);
|
|
177
182
|
return {
|
|
178
183
|
file: subject.file,
|
|
179
|
-
source:
|
|
184
|
+
source: spliceChars(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(dependency)}; // hunch deterministic source mutation\n` }]),
|
|
180
185
|
};
|
|
181
186
|
}
|
|
182
187
|
const object = symbolForSelector(base, assertion.object);
|
|
@@ -193,19 +198,19 @@ function mutateSource(policy, base, sourceFile, source) {
|
|
|
193
198
|
.map((call) => ({ start: call.atByte, end: call.endByte, text: "hunchMutationRemovedCall" }));
|
|
194
199
|
if (!replacements.length)
|
|
195
200
|
return { error: "mutation-required-call-unresolved" };
|
|
196
|
-
return { file: subject.file, source:
|
|
201
|
+
return { file: subject.file, source: spliceChars(source, replacements) };
|
|
197
202
|
}
|
|
198
203
|
if (!assertion.relation.edges.includes("calls"))
|
|
199
204
|
return { error: "mutation-call-edge-not-supported" };
|
|
200
|
-
|
|
201
|
-
const open =
|
|
205
|
+
// String search, matching definition.startByte/endByte's actual units -- see spliceChars' doc comment.
|
|
206
|
+
const open = source.indexOf("{", definition.startByte);
|
|
202
207
|
if (open < 0 || open >= definition.endByte)
|
|
203
208
|
return { error: "mutation-subject-body-unsupported" };
|
|
204
209
|
const replacements = [{ start: open + 1, end: open + 1, text: `\n ${object.name}(); // hunch deterministic source mutation\n` }];
|
|
205
210
|
if (object.file !== subject.file) {
|
|
206
211
|
const specifier = relativeSpecifier(subject.file, object.file);
|
|
207
212
|
if (!parsed.imports.includes(specifier)) {
|
|
208
|
-
const insertion =
|
|
213
|
+
const insertion = insertionPoint(source);
|
|
209
214
|
replacements.push({
|
|
210
215
|
start: insertion,
|
|
211
216
|
end: insertion,
|
|
@@ -215,7 +220,7 @@ function mutateSource(policy, base, sourceFile, source) {
|
|
|
215
220
|
}
|
|
216
221
|
return {
|
|
217
222
|
file: subject.file,
|
|
218
|
-
source:
|
|
223
|
+
source: spliceChars(source, replacements),
|
|
219
224
|
};
|
|
220
225
|
}
|
|
221
226
|
function removeWorktree(root, hooks, env, checkout) {
|
package/dist/core/config.d.ts
CHANGED
|
@@ -9,12 +9,28 @@ import type { HunchPaths } from "./paths.js";
|
|
|
9
9
|
export type Firmness = "off" | "advisory" | "firm" | "strict";
|
|
10
10
|
export declare const FIRMNESS_LEVELS: readonly Firmness[];
|
|
11
11
|
export declare const DEFAULT_FIRMNESS: Firmness;
|
|
12
|
+
/** Workspace-ledger knobs (docs/workspace-ledger.md). `publish` decides what a snapshot
|
|
13
|
+
* carries: `branches` (default — label, branches, verdicts, dirty/locked flags, no paths),
|
|
14
|
+
* `full` (worktree paths too), `off` (no record). `publish_public` lets a repo WITHOUT an
|
|
15
|
+
* overlay commit the record into its tracked .hunch/ — off by default: per-machine facts
|
|
16
|
+
* churning the code repo is rarely wanted. */
|
|
17
|
+
export type WorkspacePublish = "full" | "branches" | "off";
|
|
18
|
+
export declare const WORKSPACE_PUBLISH_MODES: readonly WorkspacePublish[];
|
|
19
|
+
export declare const DEFAULT_WORKSPACE_PUBLISH: WorkspacePublish;
|
|
20
|
+
export interface WorkspacesConfig {
|
|
21
|
+
publish: WorkspacePublish;
|
|
22
|
+
stale_after_days: number;
|
|
23
|
+
publish_public: boolean;
|
|
24
|
+
}
|
|
12
25
|
export interface HunchConfig {
|
|
13
26
|
firmness: Firmness;
|
|
14
27
|
/** MCP tool groups beyond the everyday set: `all`, `core`, or `core,nuryel`
|
|
15
28
|
* (see src/mcp/toolset.ts). Undefined = decide from the root's contents. */
|
|
16
29
|
mcp_tools?: string;
|
|
30
|
+
workspaces?: Partial<WorkspacesConfig>;
|
|
17
31
|
}
|
|
32
|
+
/** The effective workspace config: every field present, unknown values ignored. */
|
|
33
|
+
export declare function workspacesConfig(config: HunchConfig): WorkspacesConfig;
|
|
18
34
|
export declare function isFirmness(v: unknown): v is Firmness;
|
|
19
35
|
/** Read `.hunch/config.json`. A missing/unparseable file, or an unknown firmness
|
|
20
36
|
* value, falls back to defaults — the hook must NEVER crash an edit over config. */
|
package/dist/core/config.js
CHANGED
|
@@ -6,6 +6,18 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
6
6
|
import { dirname } from "node:path";
|
|
7
7
|
export const FIRMNESS_LEVELS = ["off", "advisory", "firm", "strict"];
|
|
8
8
|
export const DEFAULT_FIRMNESS = "advisory";
|
|
9
|
+
export const WORKSPACE_PUBLISH_MODES = ["full", "branches", "off"];
|
|
10
|
+
export const DEFAULT_WORKSPACE_PUBLISH = "branches";
|
|
11
|
+
/** The effective workspace config: every field present, unknown values ignored. */
|
|
12
|
+
export function workspacesConfig(config) {
|
|
13
|
+
const raw = config.workspaces ?? {};
|
|
14
|
+
const days = Number(raw.stale_after_days);
|
|
15
|
+
return {
|
|
16
|
+
publish: WORKSPACE_PUBLISH_MODES.includes(raw.publish) ? raw.publish : DEFAULT_WORKSPACE_PUBLISH,
|
|
17
|
+
stale_after_days: Number.isInteger(days) && days >= 1 && days <= 3650 ? days : 7,
|
|
18
|
+
publish_public: raw.publish_public === true,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
9
21
|
function defaults() {
|
|
10
22
|
return { firmness: DEFAULT_FIRMNESS };
|
|
11
23
|
}
|
|
@@ -22,6 +34,7 @@ export function readConfig(paths) {
|
|
|
22
34
|
return {
|
|
23
35
|
firmness: isFirmness(raw.firmness) ? raw.firmness : DEFAULT_FIRMNESS,
|
|
24
36
|
...(typeof raw.mcp_tools === "string" && raw.mcp_tools.trim() ? { mcp_tools: raw.mcp_tools.trim() } : {}),
|
|
37
|
+
...(raw.workspaces && typeof raw.workspaces === "object" && !Array.isArray(raw.workspaces) ? { workspaces: raw.workspaces } : {}),
|
|
25
38
|
};
|
|
26
39
|
}
|
|
27
40
|
catch {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface MachineIdentity {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
created_at: string;
|
|
5
|
+
}
|
|
6
|
+
export interface MachinePathOptions {
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
home?: string;
|
|
9
|
+
platform?: NodeJS.Platform;
|
|
10
|
+
}
|
|
11
|
+
export declare function machineFile(opts?: MachinePathOptions): string;
|
|
12
|
+
export declare function defaultMachineLabel(id: string): string;
|
|
13
|
+
/** The machine's identity, minted on first use. An unreadable or invalid file is
|
|
14
|
+
* replaced (a machine that lost its id simply becomes a new machine; the old record
|
|
15
|
+
* ages out as unverified and `hunch workspaces forget` removes it). */
|
|
16
|
+
export declare function loadOrCreateMachine(opts?: MachinePathOptions): MachineIdentity;
|
|
17
|
+
export declare function setMachineLabel(label: string, opts?: MachinePathOptions): MachineIdentity;
|
|
18
|
+
/** A label that equals the hostname or the OS username publishes personal data into a
|
|
19
|
+
* shared store; `doctor` and `label` warn, they do not refuse — the user chose it. */
|
|
20
|
+
export declare function labelLeaksIdentity(label: string): string | null;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine identity for the workspace ledger (docs/workspace-ledger.md): a random id
|
|
3
|
+
* generated ONCE per machine and stored at the user level, so every clone on the
|
|
4
|
+
* machine reports as the same machine. Deliberately NOT derived from the hostname,
|
|
5
|
+
* a MAC address or a hardware serial — it identifies nothing outside Hunch. The
|
|
6
|
+
* label is user-chosen; the default embeds nothing personal.
|
|
7
|
+
*
|
|
8
|
+
* Lives under the platform's per-user config root (XDG_CONFIG_HOME / %APPDATA% /
|
|
9
|
+
* ~/.config) and, like updatecheck.ts, never creates a `.hunch` path segment: that
|
|
10
|
+
* is findRoot()'s repository marker.
|
|
11
|
+
*/
|
|
12
|
+
import { lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir, hostname, userInfo } from "node:os";
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
15
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
16
|
+
import { MACHINE_ID, MACHINE_LABEL } from "./workspace.js";
|
|
17
|
+
const MAX_MACHINE_FILE_BYTES = 4096;
|
|
18
|
+
function configuredRoot(value, platform) {
|
|
19
|
+
if (!value)
|
|
20
|
+
return null;
|
|
21
|
+
const absolute = platform === "win32" ? /^(?:[A-Za-z]:[\\/]|\\\\)/.test(value) : value.startsWith("/");
|
|
22
|
+
const marker = value.replace(/\\/g, "/").split("/").some((part) => part.toLowerCase().replace(/[ .]+$/g, "") === ".hunch");
|
|
23
|
+
return absolute && !marker ? value : null;
|
|
24
|
+
}
|
|
25
|
+
export function machineFile(opts = {}) {
|
|
26
|
+
const env = opts.env ?? process.env;
|
|
27
|
+
const home = opts.home ?? homedir();
|
|
28
|
+
const platform = opts.platform ?? process.platform;
|
|
29
|
+
const configHome = configuredRoot(env.XDG_CONFIG_HOME, platform)
|
|
30
|
+
|| (platform === "win32" && configuredRoot(env.APPDATA, platform))
|
|
31
|
+
|| join(home, ".config");
|
|
32
|
+
return join(configHome, "hunch", "machine.json");
|
|
33
|
+
}
|
|
34
|
+
export function defaultMachineLabel(id) {
|
|
35
|
+
return `machine-${id.replace(/^mac_/, "").slice(0, 4)}`;
|
|
36
|
+
}
|
|
37
|
+
function readMachine(file) {
|
|
38
|
+
try {
|
|
39
|
+
const stat = lstatSync(file);
|
|
40
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_MACHINE_FILE_BYTES)
|
|
41
|
+
return null;
|
|
42
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
43
|
+
if (typeof raw.id !== "string" || !MACHINE_ID.test(raw.id))
|
|
44
|
+
return null;
|
|
45
|
+
const label = typeof raw.label === "string" && MACHINE_LABEL.test(raw.label) ? raw.label : defaultMachineLabel(raw.id);
|
|
46
|
+
const created = typeof raw.created_at === "string" && Number.isFinite(Date.parse(raw.created_at)) ? raw.created_at : new Date(0).toISOString();
|
|
47
|
+
return { id: raw.id, label, created_at: created };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Atomic, owner-only write: a half-written id file would mint a second machine. */
|
|
54
|
+
function writeMachine(file, identity) {
|
|
55
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
56
|
+
const temp = join(dirname(file), `.${basename(file)}.${process.pid}.${randomUUID()}.tmp`);
|
|
57
|
+
writeFileSync(temp, JSON.stringify(identity, null, 2) + "\n", { mode: 0o600 });
|
|
58
|
+
renameSync(temp, file);
|
|
59
|
+
}
|
|
60
|
+
/** The machine's identity, minted on first use. An unreadable or invalid file is
|
|
61
|
+
* replaced (a machine that lost its id simply becomes a new machine; the old record
|
|
62
|
+
* ages out as unverified and `hunch workspaces forget` removes it). */
|
|
63
|
+
export function loadOrCreateMachine(opts = {}) {
|
|
64
|
+
const file = machineFile(opts);
|
|
65
|
+
const existing = readMachine(file);
|
|
66
|
+
if (existing)
|
|
67
|
+
return existing;
|
|
68
|
+
const id = `mac_${randomBytes(16).toString("hex")}`;
|
|
69
|
+
const fresh = { id, label: defaultMachineLabel(id), created_at: new Date().toISOString() };
|
|
70
|
+
writeMachine(file, fresh);
|
|
71
|
+
return fresh;
|
|
72
|
+
}
|
|
73
|
+
export function setMachineLabel(label, opts = {}) {
|
|
74
|
+
if (!MACHINE_LABEL.test(label)) {
|
|
75
|
+
throw new Error("machine label must be 1-64 characters of letters, digits, '.', '_' or '-' and start with a letter or digit");
|
|
76
|
+
}
|
|
77
|
+
const next = { ...loadOrCreateMachine(opts), label };
|
|
78
|
+
writeMachine(machineFile(opts), next);
|
|
79
|
+
return next;
|
|
80
|
+
}
|
|
81
|
+
/** A label that equals the hostname or the OS username publishes personal data into a
|
|
82
|
+
* shared store; `doctor` and `label` warn, they do not refuse — the user chose it. */
|
|
83
|
+
export function labelLeaksIdentity(label) {
|
|
84
|
+
const lower = label.toLowerCase();
|
|
85
|
+
let host = "";
|
|
86
|
+
let user = "";
|
|
87
|
+
try {
|
|
88
|
+
host = hostname().toLowerCase();
|
|
89
|
+
}
|
|
90
|
+
catch { /* unavailable */ }
|
|
91
|
+
try {
|
|
92
|
+
user = userInfo().username.toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
catch { /* unavailable */ }
|
|
95
|
+
if (host && (lower === host || lower === host.split(".")[0]))
|
|
96
|
+
return "hostname";
|
|
97
|
+
if (user && lower === user)
|
|
98
|
+
return "OS username";
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=machine.js.map
|
package/dist/core/taskRecord.js
CHANGED
|
@@ -14,7 +14,7 @@ import { readFileSync } from "node:fs";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { flushCapture } from "../integrations/sync.js";
|
|
16
16
|
import { hunchPaths } from "./paths.js";
|
|
17
|
-
import { episodeTasks, isEmptyTaskReport, readTaskReport, reportHash } from "./taskReport.js";
|
|
17
|
+
import { episodeTasks, isEmptyTaskReport, readTaskReport, reportHash, sessionsOverlap } from "./taskReport.js";
|
|
18
18
|
import { reportSourceSnapshot } from "./taskReportEvidence.js";
|
|
19
19
|
import { ENTITY_KINDS, TaskRecordSchema } from "./types.js";
|
|
20
20
|
import { refreshRankEval } from "./taskRankingMode.js";
|
|
@@ -184,7 +184,10 @@ export function persistTaskRecord(root, store, taskId, options = {}) {
|
|
|
184
184
|
const members = headId === own.task.task_id && !own.task.episode ? [own.task] : episodeTasks(root, headId);
|
|
185
185
|
const reports = (members.length ? members : [own.task]).map((t) => (t.task_id === taskId ? own : readTaskReport(root, t.task_id, snapshot)));
|
|
186
186
|
const window = { from: reports[0].task.started_at, to: reports.reduce((max, r) => (r.task.finished_at && (!max || r.task.finished_at > max) ? r.task.finished_at : max), null) };
|
|
187
|
-
|
|
187
|
+
// Another session working in the same checkout at the same time leaves the
|
|
188
|
+
// same mtimes: then only commits (attributable by author and time) count.
|
|
189
|
+
const workingTree = !sessionsOverlap(root, reports[0].task.session_key, window.from, window.to, reports.map((r) => r.task.task_id));
|
|
190
|
+
let built = taskRecordFromReports(reports, gitTouchedFiles(root, window.from, window.to, { workingTree }));
|
|
188
191
|
if (!built)
|
|
189
192
|
return null;
|
|
190
193
|
let inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
|
|
@@ -194,7 +197,7 @@ export function persistTaskRecord(root, store, taskId, options = {}) {
|
|
|
194
197
|
// episode splits here: this prompt keeps its own record instead of naming
|
|
195
198
|
// private memory in a public one.
|
|
196
199
|
if (inPublic && !inPrivate && taskRecordHome(store, built) === "private" && built.id !== taskId) {
|
|
197
|
-
built = taskRecordFromReports([own], gitTouchedFiles(root, own.task.started_at, own.task.finished_at));
|
|
200
|
+
built = taskRecordFromReports([own], gitTouchedFiles(root, own.task.started_at, own.task.finished_at, { workingTree }));
|
|
198
201
|
if (!built)
|
|
199
202
|
return null;
|
|
200
203
|
inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
|
|
@@ -200,6 +200,12 @@ export interface LessonHistory {
|
|
|
200
200
|
truncated: boolean;
|
|
201
201
|
next_before: number | null;
|
|
202
202
|
}
|
|
203
|
+
/** A prompt identity that reports to another prompt's task: a host notification
|
|
204
|
+
* turn continues the session's latest task instead of opening a row. Explicit,
|
|
205
|
+
* so Stop never selects a task by recency for a prompt it does not know. */
|
|
206
|
+
export declare function aliasReportTask(root: string, aliasId: string, taskId: string): void;
|
|
207
|
+
/** The task an aliased prompt identity reports to, or the identity itself. */
|
|
208
|
+
export declare function resolveReportTask(root: string, id: string): string;
|
|
203
209
|
/** The lookup is derived, never evidence. Backfill at most 64 events and
|
|
204
210
|
* 512 KB per read. Parse outside the writer transaction so a history view cannot
|
|
205
211
|
* hold the writer lock while validating a large legacy ledger. */
|
|
@@ -217,14 +223,31 @@ export declare function startReportTask(root: string, title: string, taskId?: st
|
|
|
217
223
|
* same work: its task continues the previous one and shares its episode. */
|
|
218
224
|
export declare const CONTINUATION_WINDOW_MS: number;
|
|
219
225
|
/** The links a new prompt's task takes from the latest task of its session, or
|
|
220
|
-
* null when that task is too old (measured from its close
|
|
221
|
-
*
|
|
226
|
+
* null when that task is too old (measured from its close) to be the same work.
|
|
227
|
+
* A task still open is the session's current work however long ago it started:
|
|
228
|
+
* the prompt was interrupted before Stop, or a late observation reopened it. */
|
|
222
229
|
export declare function continuationLinks(previous: ReportTask | null, nowMs?: number): {
|
|
223
230
|
continues: string;
|
|
224
231
|
episode: string;
|
|
225
232
|
} | null;
|
|
226
233
|
/** The most recent task of a session in this worktree, or null. */
|
|
227
234
|
export declare function latestSessionTask(root: string, sessionKey: string): ReportTask | null;
|
|
235
|
+
/** Tasks of a session that an earlier prompt left open are over once the
|
|
236
|
+
* session moves on: the prompt was interrupted before its Stop, a notification
|
|
237
|
+
* turn reused the task, or a late observation reopened it. Close them as host
|
|
238
|
+
* closes, except `keepId` (the prompt now running), the session's newest task
|
|
239
|
+
* when `keepNewest` (a notification turn continues it), and any task whose
|
|
240
|
+
* verification may still deliver a result. Returns the ids closed here so the
|
|
241
|
+
* caller can persist their records. */
|
|
242
|
+
export declare function settleSessionTasks(root: string, sessionKey: string, options?: {
|
|
243
|
+
keepId?: string | null;
|
|
244
|
+
keepNewest?: boolean;
|
|
245
|
+
}): string[];
|
|
246
|
+
/** Whether a task of ANOTHER session (or of no session) was open in this
|
|
247
|
+
* worktree during the window: working-tree edits made then cannot be told apart
|
|
248
|
+
* by mtime, so the caller attributes only commits. `ownIds` are the episode's
|
|
249
|
+
* own tasks; any other task without a session key counts as foreign. */
|
|
250
|
+
export declare function sessionsOverlap(root: string, sessionKey: string | undefined, from: string, to: string | null, ownIds?: readonly string[]): boolean;
|
|
228
251
|
/** Every task of an episode, oldest first: the head and the prompts that continued it. */
|
|
229
252
|
export declare function episodeTasks(root: string, headId: string): ReportTask[];
|
|
230
253
|
/** The record revisions among `records` that this task has not received before.
|
package/dist/core/taskReport.js
CHANGED
|
@@ -115,10 +115,25 @@ function taskDb(root, run) {
|
|
|
115
115
|
);
|
|
116
116
|
CREATE INDEX IF NOT EXISTS report_record_lookup ON report_record_links(kind, record_id, content_hash);
|
|
117
117
|
CREATE TABLE IF NOT EXISTS report_history_progress (id INTEGER PRIMARY KEY CHECK(id = 1), through_rowid INTEGER NOT NULL);
|
|
118
|
-
INSERT OR IGNORE INTO report_history_progress VALUES (1, 0)
|
|
118
|
+
INSERT OR IGNORE INTO report_history_progress VALUES (1, 0);
|
|
119
|
+
CREATE TABLE IF NOT EXISTS report_task_aliases (alias_id TEXT PRIMARY KEY, task_id TEXT NOT NULL);`);
|
|
119
120
|
return run(db);
|
|
120
121
|
});
|
|
121
122
|
}
|
|
123
|
+
/** A prompt identity that reports to another prompt's task: a host notification
|
|
124
|
+
* turn continues the session's latest task instead of opening a row. Explicit,
|
|
125
|
+
* so Stop never selects a task by recency for a prompt it does not know. */
|
|
126
|
+
export function aliasReportTask(root, aliasId, taskId) {
|
|
127
|
+
TaskIdSchema.parse(aliasId);
|
|
128
|
+
taskDb(root, db => transaction(db, () => {
|
|
129
|
+
readTask(db, root, taskId);
|
|
130
|
+
db.prepare("INSERT OR REPLACE INTO report_task_aliases VALUES (?, ?)").run(aliasId, taskId);
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
/** The task an aliased prompt identity reports to, or the identity itself. */
|
|
134
|
+
export function resolveReportTask(root, id) {
|
|
135
|
+
return taskDb(root, db => db.prepare("SELECT task_id FROM report_task_aliases WHERE alias_id = ?").get(id)?.task_id ?? id);
|
|
136
|
+
}
|
|
122
137
|
function deliveryRecords(kind, body) {
|
|
123
138
|
if (kind === "save")
|
|
124
139
|
return [ReportSaveSchema.parse(body).record];
|
|
@@ -261,21 +276,69 @@ export function startReportTask(root, title, taskId, links = {}) {
|
|
|
261
276
|
* same work: its task continues the previous one and shares its episode. */
|
|
262
277
|
export const CONTINUATION_WINDOW_MS = 30 * 60_000;
|
|
263
278
|
/** The links a new prompt's task takes from the latest task of its session, or
|
|
264
|
-
* null when that task is too old (measured from its close
|
|
265
|
-
*
|
|
279
|
+
* null when that task is too old (measured from its close) to be the same work.
|
|
280
|
+
* A task still open is the session's current work however long ago it started:
|
|
281
|
+
* the prompt was interrupted before Stop, or a late observation reopened it. */
|
|
266
282
|
export function continuationLinks(previous, nowMs = Date.now()) {
|
|
267
283
|
if (!previous)
|
|
268
284
|
return null;
|
|
285
|
+
const links = { continues: previous.task_id, episode: previous.episode ?? previous.task_id };
|
|
286
|
+
if (previous.state === "open")
|
|
287
|
+
return links;
|
|
269
288
|
const reference = Date.parse(previous.finished_at ?? previous.started_at);
|
|
270
289
|
if (!Number.isFinite(reference) || nowMs - reference > CONTINUATION_WINDOW_MS)
|
|
271
290
|
return null;
|
|
272
|
-
return
|
|
291
|
+
return links;
|
|
292
|
+
}
|
|
293
|
+
function newestSessionTask(db, root, sessionKey) {
|
|
294
|
+
const row = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.session_key') = ? ORDER BY rowid DESC LIMIT 1").get(...scopePair(root), sessionKey);
|
|
295
|
+
return row ? TaskSchema.parse(JSON.parse(row.body)) : null;
|
|
273
296
|
}
|
|
274
297
|
/** The most recent task of a session in this worktree, or null. */
|
|
275
298
|
export function latestSessionTask(root, sessionKey) {
|
|
299
|
+
return taskDb(root, db => newestSessionTask(db, root, sessionKey));
|
|
300
|
+
}
|
|
301
|
+
/** Check-starts still inside their own timeout plus the grace window, minus the
|
|
302
|
+
* results that arrived: while positive, a runner may still deliver a result. */
|
|
303
|
+
function pendingChecks(db, taskId) {
|
|
304
|
+
const { pending } = db.prepare(`SELECT SUM(CASE WHEN kind = 'check-start' AND (julianday('now') - julianday(at)) * 86400000 < COALESCE(json_extract(body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending FROM report_events WHERE task_id = ?`).get(taskId);
|
|
305
|
+
return pending ?? 0;
|
|
306
|
+
}
|
|
307
|
+
/** Tasks of a session that an earlier prompt left open are over once the
|
|
308
|
+
* session moves on: the prompt was interrupted before its Stop, a notification
|
|
309
|
+
* turn reused the task, or a late observation reopened it. Close them as host
|
|
310
|
+
* closes, except `keepId` (the prompt now running), the session's newest task
|
|
311
|
+
* when `keepNewest` (a notification turn continues it), and any task whose
|
|
312
|
+
* verification may still deliver a result. Returns the ids closed here so the
|
|
313
|
+
* caller can persist their records. */
|
|
314
|
+
export function settleSessionTasks(root, sessionKey, options = {}) {
|
|
315
|
+
return taskDb(root, db => transaction(db, () => {
|
|
316
|
+
const rows = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.session_key') = ? AND json_extract(body, '$.state') = 'open' ORDER BY rowid").all(...scopePair(root), sessionKey);
|
|
317
|
+
const newest = options.keepNewest ? newestSessionTask(db, root, sessionKey)?.task_id : undefined;
|
|
318
|
+
const closed = [];
|
|
319
|
+
for (const row of rows) {
|
|
320
|
+
const task = TaskSchema.parse(JSON.parse(row.body));
|
|
321
|
+
if (task.task_id === options.keepId || task.task_id === newest || pendingChecks(db, task.task_id) > 0)
|
|
322
|
+
continue;
|
|
323
|
+
db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(TaskSchema.parse({ ...task, state: "completed", finished_at: new Date().toISOString(), closed_by: "host" })), task.task_id);
|
|
324
|
+
closed.push(task.task_id);
|
|
325
|
+
}
|
|
326
|
+
return closed;
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
/** Whether a task of ANOTHER session (or of no session) was open in this
|
|
330
|
+
* worktree during the window: working-tree edits made then cannot be told apart
|
|
331
|
+
* by mtime, so the caller attributes only commits. `ownIds` are the episode's
|
|
332
|
+
* own tasks; any other task without a session key counts as foreign. */
|
|
333
|
+
export function sessionsOverlap(root, sessionKey, from, to, ownIds = []) {
|
|
276
334
|
return taskDb(root, db => {
|
|
277
|
-
const
|
|
278
|
-
return
|
|
335
|
+
const rows = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.started_at') <= ? AND (json_extract(body, '$.finished_at') IS NULL OR json_extract(body, '$.finished_at') >= ?)").all(...scopePair(root), to ?? new Date().toISOString(), from);
|
|
336
|
+
return rows.some(r => {
|
|
337
|
+
const task = TaskSchema.parse(JSON.parse(r.body));
|
|
338
|
+
if (ownIds.includes(task.task_id))
|
|
339
|
+
return false;
|
|
340
|
+
return !sessionKey || !task.session_key || task.session_key !== sessionKey;
|
|
341
|
+
});
|
|
279
342
|
});
|
|
280
343
|
}
|
|
281
344
|
/** Every task of an episode, oldest first: the head and the prompts that continued it. */
|
|
@@ -298,36 +361,47 @@ function appendEvent(root, taskId, kind, body, eventId) {
|
|
|
298
361
|
throw new Error("report event identity conflicts with existing evidence");
|
|
299
362
|
return id;
|
|
300
363
|
}
|
|
364
|
+
// The task the observation lands on: the named one, unless the host closed
|
|
365
|
+
// it and the session has since moved on.
|
|
366
|
+
let target = task;
|
|
301
367
|
if (task.state !== "open" && !(kind === "check" && task.state === "interrupted")) {
|
|
302
368
|
if (task.closed_by !== "host")
|
|
303
369
|
throw new Error("task is already closed; start a new task for new work");
|
|
304
|
-
// The host closed this task at Stop
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
370
|
+
// The host closed this task at Stop. When a later prompt of the session
|
|
371
|
+
// exists, this is that prompt's work named by an old id (the grounding
|
|
372
|
+
// says to reuse ids): it lands on the session's newest task, which the
|
|
373
|
+
// next Stop closes, instead of reopening one no Stop would close again.
|
|
374
|
+
// Verification stays on its own task (a result must match its start), and
|
|
375
|
+
// a task the agent closed is final. Otherwise the turn went on (another
|
|
376
|
+
// hook's block, a resumed prompt): reopen; the next Stop closes it again.
|
|
377
|
+
const newest = kind === "check" || kind === "check-start" || !task.session_key ? null : newestSessionTask(db, root, task.session_key);
|
|
378
|
+
if (newest && newest.task_id !== task.task_id && (newest.state === "open" || newest.closed_by === "host"))
|
|
379
|
+
target = newest;
|
|
380
|
+
if (target.state !== "open") {
|
|
381
|
+
target = TaskSchema.parse({ ...target, state: "open", finished_at: null, closed_by: undefined });
|
|
382
|
+
db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(target), target.task_id);
|
|
383
|
+
}
|
|
308
384
|
}
|
|
385
|
+
const tid = target.task_id;
|
|
309
386
|
if (kind === "check") {
|
|
310
387
|
const check = ReportCheckSchema.parse(body);
|
|
311
|
-
if (!check.check_id || !db.prepare("SELECT event_id FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'check-start'").get(check.check_id,
|
|
388
|
+
if (!check.check_id || !db.prepare("SELECT event_id FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'check-start'").get(check.check_id, tid))
|
|
312
389
|
throw new Error("verification result has no matching start in this task");
|
|
313
390
|
}
|
|
314
|
-
const { total, bytes
|
|
315
|
-
|
|
316
|
-
SUM(CASE WHEN kind = 'check-start' AND (julianday('now') - julianday(at)) * 86400000 < COALESCE(json_extract(body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending
|
|
317
|
-
FROM report_events WHERE task_id = ?`).get(taskId);
|
|
318
|
-
const reserved = Math.max(0, (pending ?? 0) + (kind === "check-start" ? 1 : kind === "check" ? -1 : 0));
|
|
391
|
+
const { total, bytes } = db.prepare("SELECT COUNT(*) AS total, COALESCE(SUM(length(CAST(body AS BLOB))), 0) AS bytes FROM report_events WHERE task_id = ?").get(tid);
|
|
392
|
+
const reserved = Math.max(0, pendingChecks(db, tid) + (kind === "check-start" ? 1 : kind === "check" ? -1 : 0));
|
|
319
393
|
if (total + 1 + reserved > MAX_EVENTS || bytes + Buffer.byteLength(encoded) + reserved * MAX_EVENT_BYTES > MAX_TASK_BYTES)
|
|
320
394
|
throw new Error("task observation limit reached; start a new task");
|
|
321
395
|
if (kind === "claim") {
|
|
322
396
|
const claim = ReportClaimSchema.parse(body);
|
|
323
|
-
const row = db.prepare("SELECT body FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'delivery'").get(claim.occurrence_id,
|
|
397
|
+
const row = db.prepare("SELECT body FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'delivery'").get(claim.occurrence_id, tid);
|
|
324
398
|
if (!row)
|
|
325
399
|
throw new Error("claim does not refer to a delivery in this task");
|
|
326
400
|
const delivery = JSON.parse(row.body);
|
|
327
401
|
if (!delivery.records.some(r => r.record_id === claim.record_id && r.content_hash === claim.content_hash))
|
|
328
402
|
throw new Error("claim record revision was not delivered in this task");
|
|
329
403
|
}
|
|
330
|
-
const inserted = db.prepare("INSERT INTO report_events VALUES (?, ?, ?, ?, ?, ?)").run(id,
|
|
404
|
+
const inserted = db.prepare("INSERT INTO report_events VALUES (?, ?, ?, ?, ?, ?)").run(id, tid, kind, new Date().toISOString(), encoded, contentHash);
|
|
331
405
|
indexDeliveryRecords(db, id, kind, body);
|
|
332
406
|
// Advance only over contiguous observed inserts; an older writer may have
|
|
333
407
|
// left unindexed events. Historical gaps are filled by bounded reads above.
|
|
@@ -17,7 +17,10 @@ export declare function reportSourceSnapshot(root: string): ReportSnapshot;
|
|
|
17
17
|
* predicate's subject lives in a changed file. Everything else stays
|
|
18
18
|
* `not-exercised` or `unavailable` — never "satisfied" by file overlap. */
|
|
19
19
|
export declare function runReportConformance(root: string, store: HunchStore, taskId: string): ReportConformance[];
|
|
20
|
-
|
|
20
|
+
/** A full suite is the usual check; two minutes turned passing suites into
|
|
21
|
+
* recorded timeouts (#268). The bound is a safety net for an abandoned runner,
|
|
22
|
+
* not a verdict. */
|
|
23
|
+
export declare const DEFAULT_CHECK_TIMEOUT_MS: number;
|
|
21
24
|
export declare const MAX_CHECK_TIMEOUT_MS: number;
|
|
22
25
|
/** A deliberately explicit command wrapper. The caller chooses the command;
|
|
23
26
|
* reports never execute commands automatically to validate submitted claims. */
|
|
@@ -192,11 +192,14 @@ export function runReportConformance(root, store, taskId) {
|
|
|
192
192
|
return value;
|
|
193
193
|
});
|
|
194
194
|
}
|
|
195
|
-
|
|
195
|
+
/** A full suite is the usual check; two minutes turned passing suites into
|
|
196
|
+
* recorded timeouts (#268). The bound is a safety net for an abandoned runner,
|
|
197
|
+
* not a verdict. */
|
|
198
|
+
export const DEFAULT_CHECK_TIMEOUT_MS = 15 * 60_000;
|
|
196
199
|
export const MAX_CHECK_TIMEOUT_MS = 6 * 60 * 60_000;
|
|
197
200
|
/** A deliberately explicit command wrapper. The caller chooses the command;
|
|
198
201
|
* reports never execute commands automatically to validate submitted claims. */
|
|
199
|
-
export async function runReportCheck(root, taskId, command, label, timeoutMs =
|
|
202
|
+
export async function runReportCheck(root, taskId, command, label, timeoutMs = DEFAULT_CHECK_TIMEOUT_MS, options = {}) {
|
|
200
203
|
// A full suite can legitimately run for half an hour (fnd_70dd5c4034); the
|
|
201
204
|
// bound exists so an abandoned runner cannot hold a task open indefinitely.
|
|
202
205
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_CHECK_TIMEOUT_MS)
|
|
@@ -21,6 +21,18 @@ export declare function hookReportTaskId(root: string, provider: HookProvider, e
|
|
|
21
21
|
* No raw prompt, host session identifier, or transcript is retained; a repository
|
|
22
22
|
* that opts in (`taskTitles: "prompt"`) keeps only a bounded first-line title. */
|
|
23
23
|
export declare function startHookReport(root: string, provider: HookProvider, event: HunchHookInput): string | null;
|
|
24
|
+
/** A prompt the host generated to report a background command's completion,
|
|
25
|
+
* not something the user typed. */
|
|
26
|
+
export declare function isNotificationPrompt(prompt: string | undefined): boolean;
|
|
27
|
+
/** Close, as host closes, the tasks of this session that an earlier prompt left
|
|
28
|
+
* open: the prompt was interrupted before its Stop, or a late observation
|
|
29
|
+
* reopened its task. Called when a new prompt starts (`keepNewest`: the new
|
|
30
|
+
* task, or the task a notification turn continues, stays open) and when the
|
|
31
|
+
* current prompt stops. Returns the ids closed here for the caller to persist. */
|
|
32
|
+
export declare function settleHookSession(root: string, provider: HookProvider, event: HunchHookInput, options?: {
|
|
33
|
+
keepId?: string | null;
|
|
34
|
+
keepNewest?: boolean;
|
|
35
|
+
}): string[];
|
|
24
36
|
/** Stop ends the turn, so the prompt's task closes here as a HOST close: the
|
|
25
37
|
* ledger says the task completed even when the agent never called finish, and
|
|
26
38
|
* a task with observations becomes a graph record without anyone's cooperation.
|
|
@@ -28,9 +40,10 @@ export declare function startHookReport(root: string, provider: HookProvider, ev
|
|
|
28
40
|
* continuation: the next observation reopens the task and the following Stop
|
|
29
41
|
* closes it again (the record is refreshed from the report). An explicit agent
|
|
30
42
|
* finish with any outcome overrides a host close. Pending verification keeps
|
|
31
|
-
* the task open.
|
|
32
|
-
*
|
|
33
|
-
|
|
43
|
+
* the task open. Tasks an earlier prompt of the session left open close here
|
|
44
|
+
* too. Returns the ids of the tasks closed after this call, so the caller can
|
|
45
|
+
* persist their records; empty when nothing is closed. */
|
|
46
|
+
export declare function closeHookTask(root: string, provider: HookProvider, event: HunchHookInput): string[];
|
|
34
47
|
/** A presentation notice never denies Stop or injects another model turn.
|
|
35
48
|
* A prompt with no observation at all prints nothing: the empty task row stays
|
|
36
49
|
* in the ledger (hunch task list, the VS Code Contribution view) so "never
|