@fusengine/harness 0.1.91 → 0.1.93
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 +17 -0
- package/dist/adapters/claude/index.mjs +1 -1
- package/dist/adapters/codex/index.mjs +1 -1
- package/dist/adapters/cursor/index.mjs +1 -1
- package/dist/adapters/hermes/index.mjs +1 -1
- package/dist/adapters/kimi/index.mjs +1 -1
- package/dist/apex-target-Xc2M32Pl.mjs +48 -0
- package/dist/apex-task-store-Cb65rE5i.mjs +467 -0
- package/dist/{claude-Ckv2_TgP.mjs → claude-D62hkUfS.mjs} +2 -84
- package/dist/cli/bin.mjs +7 -5
- package/dist/cli/index.d.mts +69 -1
- package/dist/cli/index.mjs +2 -2
- package/dist/config/index.mjs +2 -1
- package/dist/{dotenv-BLBkBTww.mjs → dotenv-C1LkcfW-.mjs} +1 -26
- package/dist/{handle-C43gA-Pr.mjs → handle-B-g9KL_9.mjs} +4837 -3466
- package/dist/{hermes-B9-p_3IF.mjs → hermes-ByopGx6C.mjs} +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +3 -2
- package/dist/{kimi-G2wcSh5-.mjs → kimi-C-Oia9q-.mjs} +1 -1
- package/dist/{normalize-BjG6unTj.mjs → normalize-Dy8g9Ybl.mjs} +83 -3
- package/dist/policy/index.mjs +1 -1
- package/dist/prd-PvK7PKWS.mjs +494 -0
- package/dist/runtime/index.d.mts +13 -1
- package/dist/runtime/index.mjs +1 -1
- package/dist/runtime-io-DuumUeE6.mjs +84 -0
- package/dist/{session-state-D5gLr66m.d.mts → session-state-COg7Ej_2.d.mts} +32 -1
- package/dist/{skill-path-DVML3zfp.mjs → skill-path-Cz8WFaGu.mjs} +1 -1
- package/dist/{store-5-ZPKb0u.mjs → store-BVY6gIYM.mjs} +75 -3
- package/dist/tracking/index.d.mts +2 -2
- package/dist/tracking/index.mjs +2 -2
- package/dist/ttl-Dgwg_QAv.mjs +26 -0
- package/dist/{validate-KjZ1X9tH.mjs → validate-Dcjl0LUS.mjs} +4 -49
- package/package.json +1 -1
- package/src/adapters/cursor/context-budget.ts +144 -0
- package/src/adapters/cursor/context-limit.ts +115 -0
- package/src/adapters/cursor/context.ts +21 -2
- package/src/adapters/cursor/interfaces/context-budget.ts +25 -0
- package/src/adapters/cursor/native-response.ts +10 -129
- package/src/adapters/cursor/native-schemas.ts +161 -0
- package/src/adapters/cursor/normalize.ts +65 -0
- package/src/adapters/cursor/plugin-root.ts +103 -0
- package/src/adapters/cursor/respond.ts +94 -47
- package/src/cli/bin.ts +3 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/prd/compact.ts +53 -0
- package/src/cli/prd/format.ts +23 -0
- package/src/cli/prd/index.ts +23 -0
- package/src/cli/prd/resolve.ts +88 -0
- package/src/cli/prd/shared.ts +80 -0
- package/src/cli/prd/status.ts +87 -0
- package/src/cli/prd/validate.ts +90 -0
- package/src/policy/prd/index.ts +36 -0
- package/src/policy/prd/interfaces/types.ts +103 -0
- package/src/policy/prd/prd-compact.ts +28 -0
- package/src/policy/prd/prd-context.ts +131 -0
- package/src/policy/prd/prd-crosscheck.ts +76 -0
- package/src/policy/prd/prd-enabled.ts +39 -0
- package/src/policy/prd/prd-io.ts +89 -0
- package/src/policy/prd/prd-ownership.ts +99 -0
- package/src/policy/prd/prd-paths.ts +88 -0
- package/src/policy/prd/prd-schema.ts +151 -0
- package/src/runtime/handle-post.ts +2 -0
- package/src/runtime/handle-pre.ts +11 -0
- package/src/runtime/handle.ts +87 -6
- package/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts +7 -1
- package/src/runtime/lifecycle/dispatch.ts +18 -5
- package/src/runtime/lifecycle/failure-lesson.ts +6 -2
- package/src/runtime/lifecycle/rules-root.ts +18 -2
- package/src/runtime/normalize.ts +4 -0
- package/src/runtime/prd/index.ts +8 -0
- package/src/runtime/prd/prd-bash-targets.ts +199 -0
- package/src/runtime/prd/prd-candidate-files.ts +23 -0
- package/src/runtime/prd/prd-canon.ts +50 -0
- package/src/runtime/prd/prd-identity.ts +32 -0
- package/src/runtime/prd/prd-post-check.ts +73 -0
- package/src/runtime/prd/prd-pre-gate.ts +172 -0
- package/src/runtime/prd/prd-stop-gate.ts +101 -0
- package/src/runtime/prd/prd-subagent-context.ts +69 -0
- package/src/runtime/prd/prd-subagent-stop.ts +152 -0
- package/src/tracking/session-state.ts +46 -0
- package/src/tracking/track-diff.ts +3 -0
- package/src/tracking/track-journal.ts +10 -1
- package/dist/run-DkrzC0gb.mjs +0 -42
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure path builders and classifiers for the PRD file tree
|
|
3
|
+
* (`<root>/<homeSeg>/apex/prd*`). No fs access — string/path compare only.
|
|
4
|
+
*/
|
|
5
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import type { PrdPathKind, PrdRouter } from "./interfaces/types";
|
|
7
|
+
|
|
8
|
+
const AGENT_REPORT_SUFFIX = "-prd.json";
|
|
9
|
+
const DOCS_SUFFIX = ".md";
|
|
10
|
+
|
|
11
|
+
function apexDir(root: string, homeSeg: string): string {
|
|
12
|
+
return join(root, homeSeg, "apex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Absolute path to the router (`<root>/<homeSeg>/apex/prd.json`). */
|
|
16
|
+
export function prdRouterPath(root: string, homeSeg: string): string {
|
|
17
|
+
return join(apexDir(root, homeSeg), "prd.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Absolute path to the PRD directory (`<root>/<homeSeg>/apex/prd`). */
|
|
21
|
+
export function prdDir(root: string, homeSeg: string): string {
|
|
22
|
+
return join(apexDir(root, homeSeg), "prd");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Absolute path to a task-PRD file, given the router entry's `prd` field. */
|
|
26
|
+
export function prdTaskPath(root: string, homeSeg: string, relPrd: string): string {
|
|
27
|
+
return join(apexDir(root, homeSeg), relPrd);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Absolute path to an agent's own report file (`prd/agents/<agent>-prd.json`). */
|
|
31
|
+
export function prdAgentReportPath(root: string, homeSeg: string, agent: string): string {
|
|
32
|
+
return join(prdDir(root, homeSeg), "agents", `${agent}${AGENT_REPORT_SUFFIX}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Absolute path to a task's free-form doc (`prd/docs/<task>.md`). */
|
|
36
|
+
export function prdDocsPath(root: string, homeSeg: string, task: string): string {
|
|
37
|
+
return join(prdDir(root, homeSeg), "docs", `${task}${DOCS_SUFFIX}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveAgainstRoot(filePath: string, root: string): string {
|
|
41
|
+
return isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True when `filePath` (absolute or root-relative) resolves under
|
|
46
|
+
* `<root>/<homeSeg>/apex/prd/`, or is exactly the router itself. Normalizes
|
|
47
|
+
* via `path.resolve`/`relative`; a `..` that escapes the PRD dir is rejected.
|
|
48
|
+
*/
|
|
49
|
+
export function isPrdScopedPath(filePath: string, root: string, homeSeg: string): boolean {
|
|
50
|
+
const abs = resolveAgainstRoot(filePath, root);
|
|
51
|
+
if (abs === resolve(prdRouterPath(root, homeSeg))) return true;
|
|
52
|
+
const rel = relative(resolve(prdDir(root, homeSeg)), abs);
|
|
53
|
+
return rel !== "" && rel !== "." && !rel.startsWith("..") && !isAbsolute(rel);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Classifies an in-scope PRD path into one of the 4 file kinds (router,
|
|
58
|
+
* task, agentReport, docs), or `"other"` when it is textually under
|
|
59
|
+
* `apex/prd/` but matches none of the router-declared task files. `null`
|
|
60
|
+
* when the path is not in scope at all (see {@link isPrdScopedPath}).
|
|
61
|
+
*/
|
|
62
|
+
export function classifyPrdPath(
|
|
63
|
+
filePath: string,
|
|
64
|
+
root: string,
|
|
65
|
+
homeSeg: string,
|
|
66
|
+
router: PrdRouter | null,
|
|
67
|
+
): PrdPathKind | null {
|
|
68
|
+
if (!isPrdScopedPath(filePath, root, homeSeg)) return null;
|
|
69
|
+
const abs = resolveAgainstRoot(filePath, root);
|
|
70
|
+
if (abs === resolve(prdRouterPath(root, homeSeg))) return { kind: "router" };
|
|
71
|
+
|
|
72
|
+
const dir = resolve(prdDir(root, homeSeg));
|
|
73
|
+
const parts = relative(dir, abs).split(sep);
|
|
74
|
+
const [first, second] = parts;
|
|
75
|
+
|
|
76
|
+
if (parts.length === 2 && first === "agents" && second?.endsWith(AGENT_REPORT_SUFFIX)) {
|
|
77
|
+
return { kind: "agentReport", agent: second.slice(0, -AGENT_REPORT_SUFFIX.length) };
|
|
78
|
+
}
|
|
79
|
+
if (parts.length === 2 && first === "docs" && second?.endsWith(DOCS_SUFFIX)) {
|
|
80
|
+
return { kind: "docs", task: second.slice(0, -DOCS_SUFFIX.length) };
|
|
81
|
+
}
|
|
82
|
+
if (router) {
|
|
83
|
+
for (const [task, entry] of Object.entries(router)) {
|
|
84
|
+
if (resolve(prdTaskPath(root, homeSeg, entry.prd)) === abs) return { kind: "task", task };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { kind: "other" };
|
|
88
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed parsers (malformed input -> `null`, never throw) and small
|
|
3
|
+
* immutable builders for the PRD JSON contract. Pure — no fs.
|
|
4
|
+
*/
|
|
5
|
+
import type {
|
|
6
|
+
PrdAgentEntryCompacted, PrdAgentReportFile, PrdRouter, PrdRouterEntry, PrdRouterStatus,
|
|
7
|
+
PrdSubTask, PrdTaskAgentEntry, PrdTaskFile,
|
|
8
|
+
} from "./interfaces/types";
|
|
9
|
+
|
|
10
|
+
const ROUTER_STATUSES: readonly PrdRouterStatus[] = ["assigned", "in-progress", "validated"];
|
|
11
|
+
|
|
12
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
13
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isStringArray(v: unknown): v is string[] {
|
|
17
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseRouterEntry(v: unknown): PrdRouterEntry | null {
|
|
21
|
+
if (!isRecord(v)) return null;
|
|
22
|
+
if (typeof v.prd !== "string") return null;
|
|
23
|
+
if (typeof v.status !== "string" || !ROUTER_STATUSES.includes(v.status as PrdRouterStatus)) return null;
|
|
24
|
+
const validatedAt = v["validated-at"];
|
|
25
|
+
if (validatedAt !== undefined && typeof validatedAt !== "string") return null;
|
|
26
|
+
return validatedAt === undefined
|
|
27
|
+
? { prd: v.prd, status: v.status as PrdRouterStatus }
|
|
28
|
+
: { prd: v.prd, status: v.status as PrdRouterStatus, "validated-at": validatedAt };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parses the router file (`prd.json`); `null` on any malformed shape. */
|
|
32
|
+
export function parseRouter(raw: unknown): PrdRouter | null {
|
|
33
|
+
if (!isRecord(raw)) return null;
|
|
34
|
+
const out: PrdRouter = {};
|
|
35
|
+
for (const [task, v] of Object.entries(raw)) {
|
|
36
|
+
const entry = parseRouterEntry(v);
|
|
37
|
+
if (!entry) return null;
|
|
38
|
+
out[task] = entry;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseSubTask(v: unknown): PrdSubTask | null {
|
|
44
|
+
if (!isRecord(v) || typeof v.status !== "string") return null;
|
|
45
|
+
if (v.status !== "assigned" && v.status !== "validated") return null;
|
|
46
|
+
const validatedAt = v["validated-at"];
|
|
47
|
+
if (validatedAt !== undefined && typeof validatedAt !== "string") return null;
|
|
48
|
+
return validatedAt === undefined ? { status: v.status } : { status: v.status, "validated-at": validatedAt };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseAgentEntry(v: unknown): PrdTaskAgentEntry | null {
|
|
52
|
+
if (!isRecord(v) || !isStringArray(v.files)) return null;
|
|
53
|
+
if (v.status === "validated") {
|
|
54
|
+
if (typeof v["validated-at"] !== "string") return null;
|
|
55
|
+
return { status: "validated", files: v.files, "validated-at": v["validated-at"] };
|
|
56
|
+
}
|
|
57
|
+
if (!isRecord(v["sub-tasks"])) return null;
|
|
58
|
+
const subTasks: Record<string, PrdSubTask> = {};
|
|
59
|
+
for (const [sub, sv] of Object.entries(v["sub-tasks"])) {
|
|
60
|
+
const parsed = parseSubTask(sv);
|
|
61
|
+
if (!parsed) return null;
|
|
62
|
+
subTasks[sub] = parsed;
|
|
63
|
+
}
|
|
64
|
+
return { files: v.files, "sub-tasks": subTasks };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Parses a task-PRD file; `null` on any malformed shape. */
|
|
68
|
+
export function parseTaskFile(raw: unknown): PrdTaskFile | null {
|
|
69
|
+
if (!isRecord(raw)) return null;
|
|
70
|
+
const out: PrdTaskFile = {};
|
|
71
|
+
for (const [agent, v] of Object.entries(raw)) {
|
|
72
|
+
const entry = parseAgentEntry(v);
|
|
73
|
+
if (!entry) return null;
|
|
74
|
+
out[agent] = entry;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Parses an agent's own report file; `null` on any malformed shape. */
|
|
80
|
+
export function parseAgentReportFile(raw: unknown): PrdAgentReportFile | null {
|
|
81
|
+
if (!isRecord(raw)) return null;
|
|
82
|
+
const out: PrdAgentReportFile = {};
|
|
83
|
+
for (const [task, subs] of Object.entries(raw)) {
|
|
84
|
+
if (!isRecord(subs)) return null;
|
|
85
|
+
const parsedSubs: Record<string, PrdAgentReportFile[string][string]> = {};
|
|
86
|
+
for (const [sub, v] of Object.entries(subs)) {
|
|
87
|
+
if (!isRecord(v) || v.status !== "done" || !isStringArray(v.modified) || !isStringArray(v.unchanged)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const doneAt = v["done-at"];
|
|
91
|
+
if (doneAt !== undefined && typeof doneAt !== "string") return null;
|
|
92
|
+
parsedSubs[sub] = doneAt === undefined
|
|
93
|
+
? { status: "done", modified: v.modified, unchanged: v.unchanged }
|
|
94
|
+
: { status: "done", modified: v.modified, unchanged: v.unchanged, "done-at": doneAt };
|
|
95
|
+
}
|
|
96
|
+
out[task] = parsedSubs;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** True when `e` is the post-compaction shape. */
|
|
102
|
+
export function isCompacted(e: PrdTaskAgentEntry): e is PrdAgentEntryCompacted {
|
|
103
|
+
return "status" in e && e.status === "validated" && !("sub-tasks" in e);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Sub-tasks of an agent entry; `{}` when already compacted. */
|
|
107
|
+
export function subTasksOf(e: PrdTaskAgentEntry): Record<string, PrdSubTask> {
|
|
108
|
+
return isCompacted(e) ? {} : e["sub-tasks"];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Files owned by an agent entry, expanded or compacted. */
|
|
112
|
+
export function filesOf(e: PrdTaskAgentEntry): string[] {
|
|
113
|
+
return e.files;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `["needs >= 2 agents", ...]` — empty when the task PRD satisfies the contract. */
|
|
117
|
+
export function validateTaskFileInvariant(taskFile: PrdTaskFile): string[] {
|
|
118
|
+
const errors: string[] = [];
|
|
119
|
+
if (Object.keys(taskFile).length < 2) errors.push("needs >= 2 agents");
|
|
120
|
+
return errors;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Returns a new router with `task`'s status (and optional `validated-at`) updated. */
|
|
124
|
+
export function withRouterStatus(
|
|
125
|
+
router: PrdRouter,
|
|
126
|
+
task: string,
|
|
127
|
+
status: PrdRouterStatus,
|
|
128
|
+
at?: string,
|
|
129
|
+
): PrdRouter {
|
|
130
|
+
const existing = router[task];
|
|
131
|
+
if (!existing) return router;
|
|
132
|
+
const entry: PrdRouterEntry = at === undefined
|
|
133
|
+
? { prd: existing.prd, status }
|
|
134
|
+
: { prd: existing.prd, status, "validated-at": at };
|
|
135
|
+
return { ...router, [task]: entry };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Returns a new task-PRD file with one agent's sub-task flipped to `validated`. */
|
|
139
|
+
export function withSubTaskValidated(
|
|
140
|
+
taskFile: PrdTaskFile,
|
|
141
|
+
agent: string,
|
|
142
|
+
sub: string,
|
|
143
|
+
at: string,
|
|
144
|
+
): PrdTaskFile {
|
|
145
|
+
const entry = taskFile[agent];
|
|
146
|
+
if (!entry || isCompacted(entry)) return taskFile;
|
|
147
|
+
const subTask = entry["sub-tasks"][sub];
|
|
148
|
+
if (!subTask) return taskFile;
|
|
149
|
+
const nextSubTasks = { ...entry["sub-tasks"], [sub]: { status: "validated" as const, "validated-at": at } };
|
|
150
|
+
return { ...taskFile, [agent]: { files: entry.files, "sub-tasks": nextSubTasks } };
|
|
151
|
+
}
|
|
@@ -14,6 +14,7 @@ import { recordCodexPostFailure } from "../tracking/codex-post-failure";
|
|
|
14
14
|
import { defaultStateDir } from "./paths";
|
|
15
15
|
import { fanOutFiles, firstFileMatch } from "./post-fanout";
|
|
16
16
|
import { postOutcome } from "./post-outcome";
|
|
17
|
+
import { prdPostCheck } from "./prd";
|
|
17
18
|
import type { PreContext } from "./handle-pre";
|
|
18
19
|
import type { HandleOutcome } from "./handle";
|
|
19
20
|
|
|
@@ -75,6 +76,7 @@ export async function handlePost(ctx: PreContext): Promise<HandleOutcome> {
|
|
|
75
76
|
// tracking, validation, post-edit context, and notices.
|
|
76
77
|
const files = fanOutFiles(event);
|
|
77
78
|
for (const f of files) postTrackingSideEffects(opts.scope ?? "core", f, f.input, opts.now, payload, opts.cwd);
|
|
79
|
+
await prdPostCheck(id, event, opts.cwd, file, opts.now); // PRD cross-check — advisory only, never returns stdout
|
|
78
80
|
const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
|
|
79
81
|
if (seoDeny && !cursorAfterFileEdit) return { stdout: seoDeny, exit: 0 };
|
|
80
82
|
if (opts.scope === "solid") {
|
|
@@ -16,6 +16,7 @@ import { applyPatchGate } from "./apply-patch-gate";
|
|
|
16
16
|
import { isBypassPermissions } from "../adapters/codex/permission-mode";
|
|
17
17
|
import { evaluate } from "../policy/evaluate";
|
|
18
18
|
import { confirmGate } from "./confirm/confirm-gate";
|
|
19
|
+
import { prdPreGate } from "./prd";
|
|
19
20
|
import type { HandleOptions, HandleOutcome } from "./handle";
|
|
20
21
|
|
|
21
22
|
/** Context the PreToolUse pipeline needs (resolved once by {@link handleHook}). */
|
|
@@ -85,6 +86,16 @@ export async function handlePre(ctx: PreContext): Promise<HandleOutcome> {
|
|
|
85
86
|
if (taskCtx) return { stdout: taskCtx, exit: 0 };
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
// PRD (task/agent ownership coordination): inert unless FUSE_PRD=1 AND a
|
|
90
|
+
// router is present — see runtime/prd/prd-pre-gate.ts. Runs BEFORE the
|
|
91
|
+
// apply_patch gate below: `applyPatchGate` runs `evaluate()` per file, whose
|
|
92
|
+
// `runGuards()` already includes `protectedPathGuard` — which unconditionally
|
|
93
|
+
// blocks every `.claude/apex/` path, PRD included. A PRD write this gate just
|
|
94
|
+
// authorized must short-circuit past both `applyPatchGate` and the gate chain
|
|
95
|
+
// further down, never reach either.
|
|
96
|
+
const prdOutcome = await prdPreGate(id, payload, event, opts.cwd, file, opts.now);
|
|
97
|
+
if (prdOutcome) return prdOutcome;
|
|
98
|
+
|
|
88
99
|
// Codex `apply_patch`: normalize.ts fanned the freeform patch into per-file
|
|
89
100
|
// changes. OR their static verdicts — one violating hunk blocks the whole
|
|
90
101
|
// envelope. `event.files` is undefined for every other tool/harness.
|
package/src/runtime/handle.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { projectLayout } from "../config/layout";
|
|
2
3
|
import { detectFramework } from "../policy/detect-framework";
|
|
3
4
|
import { detectCreationIntent } from "../policy/creation-intent";
|
|
4
5
|
import { recordBrainstormRequired } from "../tracking/session-state";
|
|
5
6
|
import { withTrack } from "../tracking/store";
|
|
6
7
|
import { normalizeEvent } from "./normalize";
|
|
7
|
-
import { defaultStateDir, trackFile } from "./paths";
|
|
8
|
+
import { defaultStateDir, projectHash, trackFile } from "./paths";
|
|
9
|
+
import { fuseHarnessHome } from "./home-state";
|
|
8
10
|
import { designLifecycle } from "./design-lifecycle";
|
|
9
11
|
import { promptSubmitContext } from "./inject-context";
|
|
10
12
|
import { lifecycleStdout } from "./lifecycle-bridge";
|
|
@@ -22,6 +24,7 @@ import { codexPromptOrigin } from "./confirm/codex-prompt-origin";
|
|
|
22
24
|
import { cursorProjectCwd } from "../adapters/cursor/context";
|
|
23
25
|
import { toCursorLifecycleResponse } from "../adapters/cursor/respond";
|
|
24
26
|
import type { HandleOptions, HandleOutcome } from "./handle-types";
|
|
27
|
+
import type { NormalizedEvent } from "./normalize";
|
|
25
28
|
export type { HandleOptions, HandleOutcome } from "./handle-types";
|
|
26
29
|
|
|
27
30
|
/** Raw Claude hook event name from a payload (empty when absent). */
|
|
@@ -29,6 +32,62 @@ function rawEventName(payload: Record<string, unknown>): string {
|
|
|
29
32
|
return typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
/**
|
|
36
|
+
* `payload.tool_input` parsed into an object when it's a JSON STRING —
|
|
37
|
+
* Cursor's real wire format for `beforeMCPExecution`/`afterMCPExecution`
|
|
38
|
+
* (ground truth), unlike every other harness (and Cursor's own
|
|
39
|
+
* `preToolUse`/`postToolUse`), which always sends it as an object already.
|
|
40
|
+
* `undefined` when `tool_input` is already an object, absent, or fails to
|
|
41
|
+
* parse into one (fail-open — the caller then keeps the original value).
|
|
42
|
+
* @param payload - The raw hook payload.
|
|
43
|
+
*/
|
|
44
|
+
function cursorParsedToolInput(payload: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
45
|
+
const raw = payload.tool_input;
|
|
46
|
+
if (typeof raw !== "string") return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const parsed: unknown = JSON.parse(raw);
|
|
49
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined;
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `id === "cursor"` only: project the already-resolved canonical `tool_name`
|
|
57
|
+
* (`event.tool`, normalized by {@link normalizeEvent}) and `cwd` (the project
|
|
58
|
+
* root resolved via `cursorProjectCwd`, already applied to `opts.cwd`) onto a
|
|
59
|
+
* shallow payload copy — the single passage point for every downstream
|
|
60
|
+
* consumer that reads `payload.tool_name`/`payload.cwd`/`payload.tool_input`
|
|
61
|
+
* RAW instead of `event.tool`/`opts.cwd`/`event.input` (lifecycle-bridge's
|
|
62
|
+
* `failure-lesson.ts`/`agent-memory.ts`, handle-scope-async's aipilot/memory
|
|
63
|
+
* dispatchers — including `doc-cache-gate.ts`'s `libraryOf`, which never
|
|
64
|
+
* `JSON.parse`s a string `tool_input` itself — and the seo scope's
|
|
65
|
+
* `post-tool-use.ts`). `tool_input` is additionally replaced by its parsed
|
|
66
|
+
* object form via {@link cursorParsedToolInput} when Cursor sent it as a
|
|
67
|
+
* JSON string (`beforeMCPExecution`/`afterMCPExecution`). Cursor's own wire
|
|
68
|
+
* values ("Shell", `MCP:<tool>`, a bare `workspace_roots` array with no
|
|
69
|
+
* `cwd` field, a stringified `tool_input`, …) are preserved under
|
|
70
|
+
* `cursor_tool_name`/`cursor_cwd`/`cursor_tool_input` so nothing is lost.
|
|
71
|
+
* Every other harness id is untouched (returns the SAME object,
|
|
72
|
+
* byte-identical).
|
|
73
|
+
* @param payload - The raw hook payload.
|
|
74
|
+
* @param event - The already-normalized event (`event.tool` is canonical).
|
|
75
|
+
* @param cwd - The resolved project root for this invocation.
|
|
76
|
+
* @param id - Harness adapter id.
|
|
77
|
+
*/
|
|
78
|
+
function cursorRawPayloadProjection(payload: Record<string, unknown>, event: NormalizedEvent, cwd: string, id: string): Record<string, unknown> {
|
|
79
|
+
if (id !== "cursor") return payload;
|
|
80
|
+
const parsedToolInput = cursorParsedToolInput(payload);
|
|
81
|
+
return {
|
|
82
|
+
...payload,
|
|
83
|
+
cursor_tool_name: payload.tool_name,
|
|
84
|
+
cursor_cwd: payload.cwd,
|
|
85
|
+
tool_name: event.tool,
|
|
86
|
+
cwd,
|
|
87
|
+
...(parsedToolInput ? { cursor_tool_input: payload.tool_input, tool_input: parsedToolInput } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
32
91
|
/**
|
|
33
92
|
* The full hook handler: on a PRE event it gates the tool-use (stateless guards
|
|
34
93
|
* then APEX gates from the session track) and returns the native response; on a
|
|
@@ -41,6 +100,10 @@ async function handleHookCore(id: string, payload: Record<string, unknown>, opts
|
|
|
41
100
|
const cursorCwd = cursorProjectCwd(event.cwd, event.workspaceRoots ?? [], event.filePath, opts.cwd);
|
|
42
101
|
if (cursorCwd !== opts.cwd) opts = { ...opts, cwd: cursorCwd };
|
|
43
102
|
}
|
|
103
|
+
// Single passage point (see cursorRawPayloadProjection doc): every raw-payload
|
|
104
|
+
// consumer below this line gets the canonical tool_name/cwd on Cursor; every
|
|
105
|
+
// other harness id gets `payload` back untouched (byte-identical object).
|
|
106
|
+
const hookPayload = cursorRawPayloadProjection(payload, event, opts.cwd, id);
|
|
44
107
|
const rawPrompt = payload.prompt;
|
|
45
108
|
const userPrompt = typeof rawPrompt === "string" || Array.isArray(rawPrompt) ? promptText(rawPrompt) : undefined;
|
|
46
109
|
if (id === "codex" && rawEventName(payload) === "UserPromptSubmit" && userPrompt !== undefined) {
|
|
@@ -75,11 +138,11 @@ async function handleHookCore(id: string, payload: Record<string, unknown>, opts
|
|
|
75
138
|
if (id === "codex" && rawEventName(payload) === "SessionStart") resyncCodexAgents();
|
|
76
139
|
|
|
77
140
|
// Async per-scope lifecycle (aipilot cache handlers + memory-neural Graphiti).
|
|
78
|
-
const asyncOut = await asyncScopeStdout(opts.scope, rawEventName(payload),
|
|
141
|
+
const asyncOut = await asyncScopeStdout(opts.scope, rawEventName(payload), hookPayload, opts.cwd, opts.now, id);
|
|
79
142
|
if (asyncOut !== null) return { stdout: asyncOut, exit: 0 };
|
|
80
143
|
|
|
81
144
|
// Ported lifecycle/session/context hooks (SessionStart, SubagentStart/Stop, etc.).
|
|
82
|
-
const life = lifecycleStdout(
|
|
145
|
+
const life = lifecycleStdout(hookPayload, opts.cwd, opts.scope ?? "core", opts.now, id);
|
|
83
146
|
if (life !== null) {
|
|
84
147
|
// Claude-Code-only: attachBudgetRecap's systemMessage envelope assumes the
|
|
85
148
|
// Claude adapter's stdout shape (mirrors the designLifecycle gate above).
|
|
@@ -99,18 +162,36 @@ async function handleHookCore(id: string, payload: Record<string, unknown>, opts
|
|
|
99
162
|
}
|
|
100
163
|
|
|
101
164
|
if (event.phase === "post") {
|
|
102
|
-
return handlePost({ id, payload, event, framework, mcpDir, designCacheDir, file, opts });
|
|
165
|
+
return handlePost({ id, payload: hookPayload, event, framework, mcpDir, designCacheDir, file, opts });
|
|
103
166
|
}
|
|
104
167
|
|
|
105
|
-
return handlePre({ id, payload, event, framework, mcpDir, designCacheDir, file, opts });
|
|
168
|
+
return handlePre({ id, payload: hookPayload, event, framework, mcpDir, designCacheDir, file, opts });
|
|
106
169
|
}
|
|
107
170
|
|
|
108
171
|
/**
|
|
109
172
|
* Run one hook and adapt every Cursor scope outcome at the common runtime exit.
|
|
110
173
|
* Other harnesses retain the core handler's stdout and exit status unchanged.
|
|
174
|
+
* Cursor's shared `additional_context` budget context (see
|
|
175
|
+
* `../adapters/cursor/context-budget.ts`) is assembled here too — this is
|
|
176
|
+
* the single point every Cursor stdout passes through exactly once, so it's
|
|
177
|
+
* also the single point that reserves from and records into the registry.
|
|
178
|
+
* With no `session_id`/`conversation_id` at all, `sessionId` is `""` — the
|
|
179
|
+
* registry key would degenerate to one bucket shared by every session-less
|
|
180
|
+
* call on the same (cwd, event) pair, so `budget` stays `undefined` instead
|
|
181
|
+
* (falls back to the flat per-response cap in `toCursorLifecycleResponse`,
|
|
182
|
+
* with zero registry I/O). `stateDir` honors `opts.home` (test-only OS home
|
|
183
|
+
* override, see `HandleOptions`) so tests never need the real `os.homedir()`.
|
|
111
184
|
*/
|
|
112
185
|
export async function handleHook(id: string, payload: Record<string, unknown>, opts: HandleOptions): Promise<HandleOutcome> {
|
|
113
186
|
const outcome = await handleHookCore(id, payload, opts);
|
|
114
187
|
if (id !== "cursor") return outcome;
|
|
115
|
-
|
|
188
|
+
const eventName = rawEventName(payload);
|
|
189
|
+
const cursorEvent = normalizeEvent(id, payload);
|
|
190
|
+
const cwd = cursorProjectCwd(cursorEvent.cwd, cursorEvent.workspaceRoots ?? [], cursorEvent.filePath, opts.cwd);
|
|
191
|
+
const sessionId = cursorEvent.sessionId;
|
|
192
|
+
const generationId = typeof payload.generation_id === "string" && payload.generation_id ? payload.generation_id : undefined;
|
|
193
|
+
const toolUseId = typeof payload.tool_use_id === "string" && payload.tool_use_id ? payload.tool_use_id : undefined;
|
|
194
|
+
const stateDir = join(fuseHarnessHome(opts.home), "state", projectHash(cwd));
|
|
195
|
+
const budget = sessionId ? { stateDir, sessionId, event: eventName, generationId, toolUseId } : undefined;
|
|
196
|
+
return { ...outcome, stdout: toCursorLifecycleResponse(outcome.stdout, eventName, budget) };
|
|
116
197
|
}
|
|
@@ -90,7 +90,13 @@ export async function dispatchAipilot(event: string, payload: Record<string, unk
|
|
|
90
90
|
if (event === "SubagentStop") return onSubagentStop(payload, cwd, home, id);
|
|
91
91
|
// Stop too: Codex emits no SessionEnd, so its ai-pilot hooks.json wires Stop here as the sole analytics-flush trigger — reusing the SessionEnd handler verbatim (codex-plugins/docs/reference/hooks.md).
|
|
92
92
|
if (event === "SessionEnd" || event === "Stop") { await cacheAnalyticsSave(home, now); return ""; }
|
|
93
|
-
|
|
93
|
+
// "BeforeMCPExecution" is a Cursor-only lifecycle literal (produced solely
|
|
94
|
+
// by cursorEventContract in adapters/cursor/events.ts); asyncScopeStdout
|
|
95
|
+
// forwards every non-Cursor id's `hook_event_name` RAW and unvalidated, so
|
|
96
|
+
// gating on the literal alone would let a claude-code/codex payload that
|
|
97
|
+
// happens to carry this exact string reach docCacheGate — the guard must
|
|
98
|
+
// be structural (`id === "cursor"`), never event-name-only.
|
|
99
|
+
if (event === "PreToolUse" || (id === "cursor" && event === "BeforeMCPExecution")) return docCacheGate(payload, cwd, now, home, id);
|
|
94
100
|
return null;
|
|
95
101
|
}
|
|
96
102
|
|
|
@@ -17,6 +17,10 @@ import { cartoSessionStart } from "./cartographer/session-start";
|
|
|
17
17
|
import { dispatchLessons } from "./lessons/dispatch";
|
|
18
18
|
import { withSnapshot } from "./snapshot";
|
|
19
19
|
import { stopCore } from "./stop-core";
|
|
20
|
+
import { prdSubagentContext, prdSubagentStopGate, prdStopGate } from "../prd";
|
|
21
|
+
import { joinContextResponses } from "../../policy/prd";
|
|
22
|
+
import { sanitizeSessionId } from "../home-state";
|
|
23
|
+
import { defaultStateDir, trackFile } from "../paths";
|
|
20
24
|
|
|
21
25
|
/** Which plugin's hooks.json invoked the harness (selects SessionStart behavior). */
|
|
22
26
|
export type PluginScope = "core" | "solid" | "rules" | "carto" | "security" | "changelog" | "aipilot" | "lessons" | "seo" | "memory" | "tailwindcss";
|
|
@@ -70,11 +74,14 @@ export function dispatchLifecycle(input: LifecycleInput): string | null {
|
|
|
70
74
|
if (input.scope === "rules") return injectRules(resolveRulesRoot(input.id ?? "claude-code", input.cwd), input.event, input.id ?? "claude-code");
|
|
71
75
|
if (input.scope === "aipilot") return "";
|
|
72
76
|
if (input.scope === "lessons") return dispatchLessons("SubagentStart", input.payload, input.cwd, input.now, input.id ?? "claude-code");
|
|
73
|
-
return subagentCacheContext(input.payload.session_id);
|
|
74
|
-
case "Stop":
|
|
77
|
+
return joinContextResponses(subagentCacheContext(input.payload.session_id), prdSubagentContext(input.payload, input.cwd, input.id ?? "claude-code"));
|
|
78
|
+
case "Stop": {
|
|
75
79
|
if (input.scope === "lessons") return dispatchLessons("Stop", input.payload, input.cwd, input.now, input.id ?? "claude-code");
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
if (input.scope !== "core") return null;
|
|
81
|
+
const prdBlock = prdStopGate(input.payload, input.cwd, input.id ?? "claude-code", trackFile(sanitizeSessionId(input.payload.session_id) ?? "unknown", defaultStateDir(input.cwd)), input.now);
|
|
82
|
+
return prdBlock || stopCore(input.payload, input.cwd, input.now);
|
|
83
|
+
}
|
|
84
|
+
case "SubagentStop": {
|
|
78
85
|
// G0 counterpart of the SubagentStart branch above — the SAME
|
|
79
86
|
// monotone max-write, never a decrement (see confirm-subagent.ts).
|
|
80
87
|
markSubagentSeen(input.payload.session_id, input.now);
|
|
@@ -84,7 +91,13 @@ export function dispatchLifecycle(input: LifecycleInput): string | null {
|
|
|
84
91
|
// explore evidence even when sidechain PostToolUse hooks never fired
|
|
85
92
|
// (#43612/#27655/#34692). SubagentStop is main-session-dispatched (reliable).
|
|
86
93
|
harvestSubagentTrack(input.payload, input.cwd, input.now);
|
|
87
|
-
|
|
94
|
+
// null = PRD had nothing to say (off, unnamed agent, or genuinely done)
|
|
95
|
+
// -> normal trackAgentMemory handling; a non-null string (block, or ""
|
|
96
|
+
// on an already-blocked replay) must be returned AS-IS, never layered
|
|
97
|
+
// under a stale "agent completed" message.
|
|
98
|
+
const prdBlock = prdSubagentStopGate(input.payload, input.cwd, input.id ?? "claude-code", trackFile(sanitizeSessionId(input.payload.session_id) ?? "unknown", defaultStateDir(input.cwd)), input.now);
|
|
99
|
+
return prdBlock !== null ? prdBlock : trackAgentMemory(input.payload, undefined, input.now);
|
|
100
|
+
}
|
|
88
101
|
case "TeammateIdle":
|
|
89
102
|
return teammateIdleContext(input.payload, input.cwd, undefined, input.now);
|
|
90
103
|
case "PostToolUseFailure":
|
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
* matches the failure message — reusing the PreToolUse {@link lessonFor} index and
|
|
6
6
|
* its cooldown (idempotent under the ~11-process fan-out). Fail-open throughout.
|
|
7
7
|
*
|
|
8
|
-
* Claude-Code-only
|
|
9
|
-
*
|
|
8
|
+
* Claude-Code-only in the sense that no equivalent `PostToolUseFailure` hook
|
|
9
|
+
* exists on Codex or Hermes — but Cursor's own `postToolUseFailure` DOES
|
|
10
|
+
* arrive here too, via `lifecycle-bridge.ts`'s `lifecycleStdout` translating
|
|
11
|
+
* the wire event name and forwarding to `dispatchLifecycle`'s
|
|
12
|
+
* `"PostToolUseFailure"` case (see `handle.ts`'s `cursorRawPayloadProjection`
|
|
13
|
+
* for how `data.tool_name` below arrives already canonicalized on Cursor).
|
|
10
14
|
* @packageDocumentation
|
|
11
15
|
*/
|
|
12
16
|
import { homedir } from "node:os";
|
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
* Dynamic rules-plugin root resolution. The historical `CLAUDE_PLUGIN_ROOT ??
|
|
3
3
|
* cwd` chain only worked when the harness exported the plugin root — Kimi
|
|
4
4
|
* injects `KIMI_PLUGIN_ROOT` instead, and a bare cwd fallback never held a
|
|
5
|
-
* `rules/` dir.
|
|
6
|
-
*
|
|
5
|
+
* `rules/` dir.
|
|
6
|
+
*
|
|
7
|
+
* `id === "cursor"` is resolved by a SEPARATE branch (`resolveCursorPluginRoot`)
|
|
8
|
+
* before any of the below, because Cursor's env contract differs from the
|
|
9
|
+
* other harnesses (see `../../adapters/cursor/plugin-root.ts`) — it is never
|
|
10
|
+
* folded into the shared switch. For every other id, resolution order (first
|
|
11
|
+
* hit wins, unchanged):
|
|
12
|
+
* 1. `CLAUDE_PLUGIN_ROOT` (read for ALL non-cursor ids, historical quirk —
|
|
13
|
+
* frozen by non-regression tests, do not "fix" without an explicit ask);
|
|
7
14
|
* 2. `KIMI_PLUGIN_ROOT` (kimi plugin-declared hooks);
|
|
8
15
|
* 3. Per-harness install probe (claude marketplace, codex versioned cache,
|
|
9
16
|
* kimi managed plugins) — first `<plugin>/rules` dir whose plugin folder
|
|
@@ -14,6 +21,7 @@ import { existsSync, readdirSync } from "node:fs";
|
|
|
14
21
|
import { homedir } from "node:os";
|
|
15
22
|
import { join } from "node:path";
|
|
16
23
|
import { maxSemver } from "../../util/semver";
|
|
24
|
+
import { resolveCursorPluginRoot } from "../../adapters/cursor/plugin-root";
|
|
17
25
|
|
|
18
26
|
/** Immediate child dir names of `dir`, or [] when unreadable. */
|
|
19
27
|
function children(dir: string): string[] {
|
|
@@ -72,6 +80,14 @@ export function resolveRulesRoot(
|
|
|
72
80
|
cwd: string,
|
|
73
81
|
env: Record<string, string | undefined> = process.env,
|
|
74
82
|
): string {
|
|
83
|
+
if (id === "cursor") {
|
|
84
|
+
const result = resolveCursorPluginRoot(env, cwd);
|
|
85
|
+
if (result.root) return result.root;
|
|
86
|
+
process.stderr.write(
|
|
87
|
+
`[fuse-harness] cursor: no plugin root proven (checked: ${result.checked.join("; ")}); rules root falls back to ${cwd}\n`,
|
|
88
|
+
);
|
|
89
|
+
return cwd;
|
|
90
|
+
}
|
|
75
91
|
if (env.CLAUDE_PLUGIN_ROOT) return env.CLAUDE_PLUGIN_ROOT;
|
|
76
92
|
if (env.KIMI_PLUGIN_ROOT) return env.KIMI_PLUGIN_ROOT;
|
|
77
93
|
const home = env.HOME ?? homedir();
|
package/src/runtime/normalize.ts
CHANGED
|
@@ -33,6 +33,8 @@ export interface NormalizedEvent {
|
|
|
33
33
|
permissionMode?: string;
|
|
34
34
|
/** Codex logical tool-use identity, shared by sibling hook callbacks. */
|
|
35
35
|
toolUseId?: string;
|
|
36
|
+
/** Sub-agent identifier, if the tool-use came from one (Claude/Codex only — Cursor/Kimi never send this field, confirmed live). */
|
|
37
|
+
agentId?: string;
|
|
36
38
|
/** Harness-reported working directory used to scope Codex authorization. */
|
|
37
39
|
cwd?: string;
|
|
38
40
|
/** Validated Cursor multi-root workspace paths in wire order. */
|
|
@@ -71,6 +73,7 @@ export function normalizeEvent(id: string, payload: Record<string, unknown>): No
|
|
|
71
73
|
...extractCursorEvent(payload),
|
|
72
74
|
sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "",
|
|
73
75
|
agentType: str(payload.agent_type),
|
|
76
|
+
agentId: str(payload.agent_id),
|
|
74
77
|
permissionMode: str(payload.permission_mode),
|
|
75
78
|
};
|
|
76
79
|
}
|
|
@@ -83,6 +86,7 @@ export function normalizeEvent(id: string, payload: Record<string, unknown>): No
|
|
|
83
86
|
input,
|
|
84
87
|
sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "",
|
|
85
88
|
agentType: str(payload.agent_type) ?? str(input.subagent_type),
|
|
89
|
+
agentId: str(payload.agent_id),
|
|
86
90
|
permissionMode: str(payload.permission_mode),
|
|
87
91
|
toolUseId: str(payload.tool_use_id),
|
|
88
92
|
cwd: str(payload.cwd),
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Barrel for `src/runtime/prd/**` — the runtime wiring surface (design doc §2). */
|
|
2
|
+
export { prdPreGate } from "./prd-pre-gate";
|
|
3
|
+
export { prdPostCheck } from "./prd-post-check";
|
|
4
|
+
export { prdSubagentContext } from "./prd-subagent-context";
|
|
5
|
+
export { prdSubagentStopGate } from "./prd-subagent-stop";
|
|
6
|
+
export { prdStopGate } from "./prd-stop-gate";
|
|
7
|
+
export { resolvePrdIdentity } from "./prd-identity";
|
|
8
|
+
export { prdCandidateFiles } from "./prd-candidate-files";
|