@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.
Files changed (83) hide show
  1. package/README.md +17 -0
  2. package/dist/adapters/claude/index.mjs +1 -1
  3. package/dist/adapters/codex/index.mjs +1 -1
  4. package/dist/adapters/cursor/index.mjs +1 -1
  5. package/dist/adapters/hermes/index.mjs +1 -1
  6. package/dist/adapters/kimi/index.mjs +1 -1
  7. package/dist/apex-target-Xc2M32Pl.mjs +48 -0
  8. package/dist/apex-task-store-Cb65rE5i.mjs +467 -0
  9. package/dist/{claude-Ckv2_TgP.mjs → claude-D62hkUfS.mjs} +2 -84
  10. package/dist/cli/bin.mjs +7 -5
  11. package/dist/cli/index.d.mts +69 -1
  12. package/dist/cli/index.mjs +2 -2
  13. package/dist/config/index.mjs +2 -1
  14. package/dist/{dotenv-BLBkBTww.mjs → dotenv-C1LkcfW-.mjs} +1 -26
  15. package/dist/{handle-C43gA-Pr.mjs → handle-B-g9KL_9.mjs} +4837 -3466
  16. package/dist/{hermes-B9-p_3IF.mjs → hermes-ByopGx6C.mjs} +1 -1
  17. package/dist/index.d.mts +1 -1
  18. package/dist/index.mjs +3 -2
  19. package/dist/{kimi-G2wcSh5-.mjs → kimi-C-Oia9q-.mjs} +1 -1
  20. package/dist/{normalize-BjG6unTj.mjs → normalize-Dy8g9Ybl.mjs} +83 -3
  21. package/dist/policy/index.mjs +1 -1
  22. package/dist/prd-PvK7PKWS.mjs +494 -0
  23. package/dist/runtime/index.d.mts +13 -1
  24. package/dist/runtime/index.mjs +1 -1
  25. package/dist/runtime-io-DuumUeE6.mjs +84 -0
  26. package/dist/{session-state-D5gLr66m.d.mts → session-state-COg7Ej_2.d.mts} +32 -1
  27. package/dist/{skill-path-DVML3zfp.mjs → skill-path-Cz8WFaGu.mjs} +1 -1
  28. package/dist/{store-5-ZPKb0u.mjs → store-BVY6gIYM.mjs} +75 -3
  29. package/dist/tracking/index.d.mts +2 -2
  30. package/dist/tracking/index.mjs +2 -2
  31. package/dist/ttl-Dgwg_QAv.mjs +26 -0
  32. package/dist/{validate-KjZ1X9tH.mjs → validate-Dcjl0LUS.mjs} +4 -49
  33. package/package.json +1 -1
  34. package/src/adapters/cursor/context-budget.ts +144 -0
  35. package/src/adapters/cursor/context-limit.ts +115 -0
  36. package/src/adapters/cursor/context.ts +21 -2
  37. package/src/adapters/cursor/interfaces/context-budget.ts +25 -0
  38. package/src/adapters/cursor/native-response.ts +10 -129
  39. package/src/adapters/cursor/native-schemas.ts +161 -0
  40. package/src/adapters/cursor/normalize.ts +65 -0
  41. package/src/adapters/cursor/plugin-root.ts +103 -0
  42. package/src/adapters/cursor/respond.ts +94 -47
  43. package/src/cli/bin.ts +3 -0
  44. package/src/cli/index.ts +1 -0
  45. package/src/cli/prd/compact.ts +53 -0
  46. package/src/cli/prd/format.ts +23 -0
  47. package/src/cli/prd/index.ts +23 -0
  48. package/src/cli/prd/resolve.ts +88 -0
  49. package/src/cli/prd/shared.ts +80 -0
  50. package/src/cli/prd/status.ts +87 -0
  51. package/src/cli/prd/validate.ts +90 -0
  52. package/src/policy/prd/index.ts +36 -0
  53. package/src/policy/prd/interfaces/types.ts +103 -0
  54. package/src/policy/prd/prd-compact.ts +28 -0
  55. package/src/policy/prd/prd-context.ts +131 -0
  56. package/src/policy/prd/prd-crosscheck.ts +76 -0
  57. package/src/policy/prd/prd-enabled.ts +39 -0
  58. package/src/policy/prd/prd-io.ts +89 -0
  59. package/src/policy/prd/prd-ownership.ts +99 -0
  60. package/src/policy/prd/prd-paths.ts +88 -0
  61. package/src/policy/prd/prd-schema.ts +151 -0
  62. package/src/runtime/handle-post.ts +2 -0
  63. package/src/runtime/handle-pre.ts +11 -0
  64. package/src/runtime/handle.ts +87 -6
  65. package/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts +7 -1
  66. package/src/runtime/lifecycle/dispatch.ts +18 -5
  67. package/src/runtime/lifecycle/failure-lesson.ts +6 -2
  68. package/src/runtime/lifecycle/rules-root.ts +18 -2
  69. package/src/runtime/normalize.ts +4 -0
  70. package/src/runtime/prd/index.ts +8 -0
  71. package/src/runtime/prd/prd-bash-targets.ts +199 -0
  72. package/src/runtime/prd/prd-candidate-files.ts +23 -0
  73. package/src/runtime/prd/prd-canon.ts +50 -0
  74. package/src/runtime/prd/prd-identity.ts +32 -0
  75. package/src/runtime/prd/prd-post-check.ts +73 -0
  76. package/src/runtime/prd/prd-pre-gate.ts +172 -0
  77. package/src/runtime/prd/prd-stop-gate.ts +101 -0
  78. package/src/runtime/prd/prd-subagent-context.ts +69 -0
  79. package/src/runtime/prd/prd-subagent-stop.ts +152 -0
  80. package/src/tracking/session-state.ts +46 -0
  81. package/src/tracking/track-diff.ts +3 -0
  82. package/src/tracking/track-journal.ts +10 -1
  83. 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.
@@ -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), payload, opts.cwd, opts.now, id);
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(payload, opts.cwd, opts.scope ?? "core", opts.now, id);
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
- return { ...outcome, stdout: toCursorLifecycleResponse(outcome.stdout, rawEventName(payload)) };
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
- if (event === "PreToolUse") return docCacheGate(payload, cwd, now, home, id);
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
- return input.scope === "core" ? stopCore(input.payload, input.cwd, input.now) : null;
77
- case "SubagentStop":
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
- return trackAgentMemory(input.payload, undefined, input.now);
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: no equivalent `PostToolUseFailure` hook exists on Codex or
9
- * Hermes, so this handler is never reached through those adapters.
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. Resolution order (first hit wins):
6
- * 1. `CLAUDE_PLUGIN_ROOT` (claude-code/codex plugin-declared hooks);
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();
@@ -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";