@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,199 @@
1
+ /**
2
+ * @module prd-bash-targets
3
+ * Local, PRD-module-only extension of Bash write-target detection BEYOND
4
+ * shell output redirects (`>`/`>>`, already covered by `shellOutputRedirects`
5
+ * in `bash-write-redirects.ts`). Static, best-effort heuristic scanner (never
6
+ * executes the command) for the write-target argument of common non-redirect
7
+ * write verbs: `cp`, `mv`, `install` (last positional = destination), `tee`
8
+ * (every positional = a destination), `sed -i` / `perl -i` (in-place edit —
9
+ * last positional = the edited file), and `dd of=` (the `of=` operand).
10
+ *
11
+ * Deliberately NOT merged into `bash-write-redirects.ts` or
12
+ * `protectedPathGuard` (`protected-path.ts`) — both are shared, harness-wide
13
+ * guards; changing them risks a global regression. This is a narrow,
14
+ * PRD-scoped sibling used ONLY by `prdPreGate`'s Bash branch, feeding its
15
+ * output through the SAME `isPrdScopedPath` check the redirect targets
16
+ * already go through — so a target outside the PRD tree is never affected.
17
+ *
18
+ * Known limitation: `--` (POSIX end-of-options, GNU coreutils/glibc
19
+ * `getopt_long`) is honored — a positional arg starting with `-` AFTER `--`
20
+ * is never mistaken for an option — but `getopt_long`'s default PERMUTE mode
21
+ * lets a value-taking option (`install -m 644 file dest`, `cp -t DIR a b`)
22
+ * land anywhere in argv; this scanner doesn't track which options consume a
23
+ * following value, so a bare value like `644` could in principle be
24
+ * mistaken for the destination if it were the LAST token. Best-effort only
25
+ * (mirrors the existing `protectedPathGuard#extractWriteTargets`
26
+ * precedent): can under/mis-detect an unusual invocation, never cause a
27
+ * false deny on an out-of-scope path (the caller still scope-checks every
28
+ * returned target). Not applied to `dd`, whose `if=`/`of=` operands are
29
+ * never getopt-parsed (coreutils docs: "the only options are
30
+ * --help/--version").
31
+ *
32
+ * 2nd limitation (unfixed, flagged for owner): bundled `-i` (`perl -pi -e`,
33
+ * `sed -ni`) is missed — only a standalone `-i`/`-i<suffix>` token is seen.
34
+ */
35
+
36
+ /** Chain separators this splits a command on (quote/paren-depth aware — never splits inside quotes or `$(...)`/backticks). */
37
+ const CHAIN_CHARS = new Set([";", "&", "|", "\n"]);
38
+
39
+ /** Balanced-paren scan for a `$(...)` body, quote-aware. @returns Index of the matching `)`, or `input.length` if unterminated. */
40
+ function closingParen(input: string, start: number): number {
41
+ let depth = 1;
42
+ let quote: "'" | '"' | null = null;
43
+ for (let i = start; i < input.length; i++) {
44
+ const ch = input[i];
45
+ if (ch === "\\" && quote !== "'") { i++; continue; }
46
+ if (quote) { if (ch === quote) quote = null; continue; }
47
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
48
+ if (ch === "(") depth++;
49
+ else if (ch === ")" && --depth === 0) return i;
50
+ }
51
+ return input.length;
52
+ }
53
+
54
+ /**
55
+ * Splits `input[start, end)` into "simple command" strings on unquoted chain
56
+ * separators, recursing into `$(...)`/backtick substitutions so a write verb
57
+ * hidden inside a subshell is still found. Bounded: every recursive call
58
+ * strictly narrows `[start, end)`.
59
+ * @param out - Accumulator for discovered simple-command strings.
60
+ */
61
+ function scanCommands(input: string, start: number, end: number, out: string[]): void {
62
+ let cmdStart = start;
63
+ let quote: "'" | '"' | null = null;
64
+ const flush = (to: number): void => {
65
+ const seg = input.slice(cmdStart, to).trim();
66
+ if (seg) out.push(seg);
67
+ };
68
+ for (let i = start; i < end; i++) {
69
+ const ch = input[i];
70
+ if (ch === "\\" && quote !== "'") { i++; continue; }
71
+ // Single quotes are fully opaque (bash: NO substitution inside `'...'`).
72
+ // Double quotes still perform command substitution (bash: `"$(...)"` and
73
+ // `` "`...`" `` both still execute) — only word-splitting/globbing is
74
+ // suppressed — so `$(`/backtick must still be checked while `quote==='"'`.
75
+ if (quote === "'") { if (ch === "'") quote = null; continue; }
76
+ if (ch === "$" && input[i + 1] === "(") {
77
+ const close = closingParen(input, i + 2);
78
+ scanCommands(input, i + 2, close, out);
79
+ i = close;
80
+ continue;
81
+ }
82
+ if (ch === "`") {
83
+ const close = input.indexOf("`", i + 1);
84
+ const safeClose = close === -1 || close > end ? end : close;
85
+ scanCommands(input, i + 1, safeClose, out);
86
+ i = safeClose;
87
+ continue;
88
+ }
89
+ if (quote === '"') { if (ch === '"') quote = null; continue; }
90
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
91
+ if (CHAIN_CHARS.has(ch ?? "")) {
92
+ flush(i);
93
+ cmdStart = i + 1;
94
+ continue;
95
+ }
96
+ }
97
+ flush(end);
98
+ }
99
+
100
+ /**
101
+ * Tokenizes one simple-command string into unquoted words (quotes unwrapped,
102
+ * backslash-escapes resolved outside single quotes) — same unquoting
103
+ * contract as `bash-write-redirects.ts`'s own `readTarget`.
104
+ */
105
+ function tokenize(segment: string): string[] {
106
+ const tokens: string[] = [];
107
+ let cur = "";
108
+ let quote: "'" | '"' | null = null;
109
+ let started = false;
110
+ for (let i = 0; i < segment.length; i++) {
111
+ const ch = segment[i] ?? "";
112
+ if (ch === "\\" && quote !== "'") {
113
+ if (i + 1 < segment.length) { cur += segment[++i] ?? ""; started = true; }
114
+ continue;
115
+ }
116
+ if (quote) {
117
+ if (ch === quote) quote = null; else cur += ch;
118
+ continue;
119
+ }
120
+ if (ch === "'" || ch === '"') { quote = ch; started = true; continue; }
121
+ if (/\s/.test(ch)) {
122
+ if (started) { tokens.push(cur); cur = ""; started = false; }
123
+ continue;
124
+ }
125
+ cur += ch;
126
+ started = true;
127
+ }
128
+ if (started) tokens.push(cur);
129
+ return tokens;
130
+ }
131
+
132
+ /** True for a short/long option token (never a bare `-`, the stdin/stdout idiom). */
133
+ function isOption(t: string): boolean {
134
+ return t.startsWith("-") && t !== "-";
135
+ }
136
+
137
+ /**
138
+ * Positional (non-option) arguments, honoring `--` as POSIX end-of-options
139
+ * (see module doc): every token after a literal `--` is positional even if
140
+ * it starts with `-`.
141
+ */
142
+ function positionalArgs(args: string[]): string[] {
143
+ const out: string[] = [];
144
+ let optionsEnded = false;
145
+ for (const t of args) {
146
+ if (!optionsEnded && t === "--") { optionsEnded = true; continue; }
147
+ if (!optionsEnded && isOption(t)) continue;
148
+ out.push(t);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /**
154
+ * Write-target argument(s) of one verb invocation, given its ARGUMENTS (verb
155
+ * token excluded). Empty when the verb isn't covered, or has no destination.
156
+ * @param verb - The verb basename (leading directory component stripped).
157
+ */
158
+ function verbWriteTargets(verb: string, args: string[]): string[] {
159
+ if (verb === "dd") {
160
+ const of = args.find((t) => t.startsWith("of="));
161
+ return of ? [of.slice(3)] : [];
162
+ }
163
+ if (verb === "tee") return positionalArgs(args);
164
+ if (verb === "cp" || verb === "mv" || verb === "install") {
165
+ const p = positionalArgs(args);
166
+ return p.length > 0 ? [p[p.length - 1] ?? ""] : [];
167
+ }
168
+ if (verb === "sed" || verb === "perl") {
169
+ const inPlace = args.some((t) => t.startsWith("-i") || t === "--in-place" || t.startsWith("--in-place="));
170
+ if (!inPlace) return [];
171
+ const p = positionalArgs(args);
172
+ return p.length > 0 ? [p[p.length - 1] ?? ""] : [];
173
+ }
174
+ return [];
175
+ }
176
+
177
+ /**
178
+ * Extracts candidate write-target paths from a Bash command string, for the
179
+ * non-redirect write verbs this module covers (`cp`, `mv`, `install`, `tee`,
180
+ * `sed -i`/`perl -i`, `dd of=`). Read-only usages (a verb that never writes,
181
+ * or a write verb whose only in-scope path is a SOURCE argument) never
182
+ * contribute a target — callers still resolve/scope-check every returned
183
+ * path themselves (this function does no fs access, no scoping decision).
184
+ * @param command - The raw Bash command string.
185
+ * @returns Candidate write-target paths (possibly empty/duplicated).
186
+ */
187
+ export function extraBashWriteTargets(command: string): string[] {
188
+ const segments: string[] = [];
189
+ scanCommands(command, 0, command.length, segments);
190
+ const out: string[] = [];
191
+ for (const seg of segments) {
192
+ const tokens = tokenize(seg);
193
+ const verbToken = tokens[0];
194
+ if (!verbToken) continue;
195
+ const verb = verbToken.split("/").pop() ?? verbToken;
196
+ out.push(...verbWriteTargets(verb, tokens.slice(1)));
197
+ }
198
+ return out;
199
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @module prd-candidate-files
3
+ * Shared candidate-path extraction for a hook event, used by both
4
+ * {@link prdPreGate} and {@link prdPostCheck} — the fanned-out
5
+ * `apply_patch`/`afterFileEdit` set when present, else the single
6
+ * `event.filePath`.
7
+ */
8
+ import type { NormalizedEvent } from "../normalize";
9
+
10
+ /**
11
+ * @param event - The normalized hook event.
12
+ * @param restrictToWrite - When true (PreToolUse — the default), a bare
13
+ * `event.filePath` only counts for `Write`/`Edit` (never a `Read`, which must
14
+ * stay unaffected by the ownership gate). PostToolUse call sites pass
15
+ * `false`: they only ever see a real Write/Edit-shaped event already.
16
+ * @returns The candidate file paths (possibly empty).
17
+ */
18
+ export function prdCandidateFiles(event: NormalizedEvent, restrictToWrite = true): string[] {
19
+ if (event.files?.length) return event.files.map((f) => f.filePath);
20
+ if (!event.filePath) return [];
21
+ if (restrictToWrite && event.tool !== "Write" && event.tool !== "Edit") return [];
22
+ return [event.filePath];
23
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @module prd-canon
3
+ * Best-effort symlink canonicalization for the paths `prdPreGate`/
4
+ * `prdPostCheck` feed into lot A's pure `isPrdScopedPath`/`classifyPrdPath`
5
+ * (documented "string/path compare only — no fs", by design). Those
6
+ * comparisons need `root` and each candidate file path on the SAME
7
+ * representation — but on macOS, `process.cwd()` inside a spawned process
8
+ * resolves `/var` to its canonical `/private/var` while a caller-constructed
9
+ * absolute path (e.g. from `os.tmpdir()`) may still carry the unresolved
10
+ * `/var` alias, so two paths naming the identical file compare unequal.
11
+ * Same `realpathSync.native` + raw-path-on-failure idiom already used by
12
+ * `adapters/cursor/context.ts`'s `cursorAbsolutePath`.
13
+ */
14
+ import { realpathSync } from "node:fs";
15
+ import { isAbsolute, join, sep } from "node:path";
16
+
17
+ /** Canonicalizes an existing directory (e.g. the project root); the raw value on failure. */
18
+ export function canonicalRoot(root: string): string {
19
+ try {
20
+ return realpathSync.native(root);
21
+ } catch {
22
+ return root;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Canonicalizes an absolute file path that may not exist yet — including
28
+ * under a brand-new subdirectory (e.g. a task's FIRST `prd/docs/<task>.md`,
29
+ * whose `docs/` dir doesn't exist yet either): walks up from the full path,
30
+ * dropping one segment at a time, until an ancestor resolves, then rejoins
31
+ * the non-existent tail onto that canonical prefix. `root` itself always
32
+ * resolves (guaranteed present by `isPrdEnabled`), so this always terminates
33
+ * on a real answer for any path actually under the project root. A relative
34
+ * path is returned as-is (no `root` to anchor a symlink comparison against).
35
+ */
36
+ export function canonicalFilePath(path: string): string {
37
+ if (!isAbsolute(path)) return path;
38
+ const parts = path.split(sep);
39
+ for (let end = parts.length; end > 0; end--) {
40
+ const prefix = parts.slice(0, end).join(sep) || sep;
41
+ try {
42
+ const real = realpathSync.native(prefix);
43
+ const tail = parts.slice(end);
44
+ return tail.length > 0 ? join(real, ...tail) : real;
45
+ } catch {
46
+ continue; // try a shorter, more likely to exist, ancestor
47
+ }
48
+ }
49
+ return path;
50
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @module prd-identity
3
+ * Resolve a {@link PrdIdentity} from a normalized hook event. Structural, not
4
+ * field-presence-based: Cursor and Kimi NEVER carry `agent_id`/`agent_type` on
5
+ * a sub-agent's own tool-use (live-captured — see the PRD design doc Risks
6
+ * §1/§2), so on those two targets the absence of `agentId` is NOT evidence of
7
+ * being the lead — it is a harness ceiling, and the verdict must stay
8
+ * `"unknown"` (advisory, never a hard block) regardless of what the payload
9
+ * carries. Every other target (claude-code, codex, and any future harness
10
+ * sharing their schema) DOES carry `agent_id` on a real sub-agent call, so
11
+ * there `agentId`'s absence structurally proves "this is the lead".
12
+ */
13
+ import type { NormalizedEvent } from "../normalize";
14
+ import type { PrdIdentity } from "../../policy/prd/interfaces/types";
15
+
16
+ /** Harness targets whose payloads never carry per-event agent identity (live-confirmed for Cursor; documented schema for Kimi — design doc Risks §1/§2). */
17
+ const NO_IDENTITY_SUPPORT: ReadonlySet<string> = new Set(["cursor", "kimi"]);
18
+
19
+ /**
20
+ * Resolve the PRD identity of the current tool-use.
21
+ * @param id - Harness target id (e.g. "claude-code", "codex", "cursor", "kimi").
22
+ * @param event - The normalized hook event (reads `agentId`/`agentType`).
23
+ * @returns The resolved {@link PrdIdentity} — `lead: "unknown"` on Cursor/Kimi,
24
+ * `lead: true` when no `agentId` is present elsewhere, `lead: false` (with
25
+ * whatever `agentType` came along, possibly undefined — see design Risks §4)
26
+ * otherwise.
27
+ */
28
+ export function resolvePrdIdentity(id: string, event: NormalizedEvent): PrdIdentity {
29
+ if (NO_IDENTITY_SUPPORT.has(id)) return { lead: "unknown", agentId: event.agentId, agentType: event.agentType };
30
+ if (!event.agentId) return { lead: true };
31
+ return { lead: false, agentId: event.agentId, agentType: event.agentType };
32
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @module prd-post-check
3
+ * PostToolUse cross-check (design doc §2.2). Side-effect only — never
4
+ * produces stdout. Fires only when the touched path just resolved to a
5
+ * task-PRD or the router (the only two places a `validated` status is
6
+ * asserted), reloads every named agent's own report, and journals any NEW
7
+ * cross-check violation (deduped by `{task,agent,sub,reason}` so a repeated
8
+ * PostToolUse on the same file never re-appends the same finding).
9
+ */
10
+ import { hashText } from "../../util/json-io";
11
+ import { harnessHomeSegment } from "../../policy/apex-target";
12
+ import {
13
+ classifyPrdPath, crossCheckTask, isPrdEnabled, prdProjectRoot,
14
+ readAgentReport, readRouter, readTaskFile,
15
+ type PrdAgentReportFile, type PrdCrossCheckViolation,
16
+ } from "../../policy/prd";
17
+ import { withTrack } from "../../tracking/store";
18
+ import { recordPrdViolation } from "../../tracking/session-state";
19
+ import { prdCandidateFiles } from "./prd-candidate-files";
20
+ import { canonicalFilePath, canonicalRoot } from "./prd-canon";
21
+ import type { NormalizedEvent } from "../normalize";
22
+
23
+ /** Stable dedup key for one violation, shared across replays of the same PostToolUse. */
24
+ function violationKey(v: PrdCrossCheckViolation): string {
25
+ return hashText(JSON.stringify([v.task, v.agent, v.sub, v.reason]));
26
+ }
27
+
28
+ /**
29
+ * Run the PRD PostToolUse cross-check. Side-effect only — journals new
30
+ * violations, never returns anything to render.
31
+ */
32
+ export async function prdPostCheck(id: string, event: NormalizedEvent, cwd: string, trackFilePath: string, now: number): Promise<void> {
33
+ if (!isPrdEnabled(cwd, id)) return;
34
+ const root = canonicalRoot(prdProjectRoot(cwd)); // see prd-canon.ts — same representation as the candidate file paths below
35
+ const homeSeg = harnessHomeSegment(id);
36
+ const router = await readRouter(root, homeSeg);
37
+ if (!router) return;
38
+
39
+ const files = prdCandidateFiles(event, false).map(canonicalFilePath);
40
+ const touchedTasks = new Set<string>();
41
+ for (const f of files) {
42
+ const kind = classifyPrdPath(f, root, homeSeg, router);
43
+ if (kind?.kind === "task") touchedTasks.add(kind.task);
44
+ else if (kind?.kind === "router") for (const task of Object.keys(router)) touchedTasks.add(task);
45
+ }
46
+ if (touchedTasks.size === 0) return;
47
+
48
+ const violations: PrdCrossCheckViolation[] = [];
49
+ for (const task of touchedTasks) {
50
+ const entry = router[task];
51
+ if (!entry) continue;
52
+ const taskFile = await readTaskFile(root, homeSeg, entry.prd);
53
+ if (!taskFile) continue;
54
+ const reports: Record<string, PrdAgentReportFile> = {};
55
+ for (const agent of Object.keys(taskFile)) {
56
+ const report = await readAgentReport(root, homeSeg, agent);
57
+ if (report) reports[agent] = report;
58
+ }
59
+ violations.push(...crossCheckTask(taskFile, reports, task));
60
+ }
61
+ if (violations.length === 0) return;
62
+
63
+ await withTrack(trackFilePath, (t) => {
64
+ const known = new Set((t.prdViolations ?? []).map(violationKey));
65
+ let next = t;
66
+ for (const v of violations) {
67
+ if (known.has(violationKey(v))) continue;
68
+ known.add(violationKey(v));
69
+ next = recordPrdViolation(next, { ...v, ts: now });
70
+ }
71
+ return next;
72
+ });
73
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * @module prd-pre-gate
3
+ * PreToolUse orchestrator for the PRD module (design doc §2.0/§2.1). Inert
4
+ * unless {@link isPrdEnabled}: one env read + one `statSync`, zero further
5
+ * disk access. When active, denies a Bash write under `apex/prd/` for
6
+ * EVERYONE (no identity check needed — see below) — covering both shell
7
+ * output redirects (`>`/`>>`, `shellOutputRedirects`) AND the non-redirect
8
+ * write verbs `cp`, `mv`, `tee`, `sed -i`/`perl -i`, `install`, `dd of=`
9
+ * (`extraBashWriteTargets`, `prd-bash-targets.ts`) — then resolves ownership
10
+ * for Write/Edit/apply_patch/afterFileEdit targets and either short-circuits
11
+ * past `gate()`/`protectedPathGuard` with an allow, or returns the standard
12
+ * deny — never both, never a silent pass-through of a denied write.
13
+ */
14
+ import { dirname } from "node:path";
15
+ import type { Prompt } from "../../prompt/types";
16
+ import { respond } from "../respond";
17
+ import { withDenyNotice } from "../deny-notice";
18
+ import { allowOutcome } from "../pre-allow";
19
+ import { projectLayout } from "../../config/layout";
20
+ import { harnessHomeSegment } from "../../policy/apex-target";
21
+ import { shellOutputRedirects } from "../../policy/guards/bash-write-redirects";
22
+ import { extraBashWriteTargets } from "./prd-bash-targets";
23
+ import {
24
+ candidateAgentNames, classifyPrdPath, evaluateWriteOwnership, isPrdEnabled, isPrdScopedPath,
25
+ prdProjectRoot, readAllTaskFiles, readRouter,
26
+ type PrdOwnershipVerdict, type PrdPathKind, type PrdTaskFile,
27
+ } from "../../policy/prd";
28
+ import { loadTrack, withTrack } from "../../tracking/store";
29
+ import { recordPrdOwner } from "../../tracking/session-state";
30
+ import { resolvePrdIdentity } from "./prd-identity";
31
+ import { prdCandidateFiles } from "./prd-candidate-files";
32
+ import { canonicalFilePath, canonicalRoot } from "./prd-canon";
33
+ import type { NormalizedEvent } from "../normalize";
34
+ import type { HandleOutcome } from "../handle";
35
+
36
+ const ACTIONS = ["Write only the files/report this agent owns per its PRD slice", "Run `harness prd status` to see the current assignment"];
37
+
38
+ function denyOutcome(id: string, event: NormalizedEvent, trackFilePath: string, now: number, reason: string): HandleOutcome {
39
+ const prompt: Prompt = { kind: "block", ruleId: "prd-ownership", title: "PRD ownership", reason, actions: ACTIONS };
40
+ return { stdout: withDenyNotice(id, respond(id, prompt, event.eventName ?? "PreToolUse"), prompt, event.sessionId, dirname(trackFilePath), now), exit: 0 };
41
+ }
42
+
43
+ /**
44
+ * Narrows `taskFile` to the names `evaluateWriteOwnership` needs to correctly
45
+ * resolve a SPECIFIC requested agent-report name, WITHOUT changing lot A's
46
+ * pure `resolveOwnerBinding`/`evaluateWriteOwnership` (unmodified) — a lot B
47
+ * integration adapter around a real call-site gap, not a policy change.
48
+ *
49
+ * Two rules, matching the design's "first-come, exact-name-first" contract:
50
+ * - `target === agentType` (claiming YOUR OWN exact name): always legitimate
51
+ * on a cold start — narrow to just `target`, so a same-type SIBLING's mere
52
+ * existence never manufactures a false "ambiguous" (`resolveOwnerBinding`
53
+ * otherwise sees 2 equally-free candidates and gives up for BOTH).
54
+ * - `target !== agentType` (claiming a "-n" SIBLING slot): only legitimate
55
+ * once the EXACT-match name is already claimed by someone else — keep
56
+ * BOTH names, so `resolveOwnerBinding` sees 2 free candidates (denied,
57
+ * ambiguous) until the exact slot is taken, then exactly 1 (allowed).
58
+ * In every case, this agentId's own EXISTING binding (if any) and every
59
+ * name already claimed by anyone are also kept, so a mismatch against an
60
+ * established binding is still caught (never silently dropped from view).
61
+ */
62
+ function narrowForTarget(taskFile: PrdTaskFile, agentType: string, target: string, agentId: string, bindings: Record<string, string>): PrdTaskFile {
63
+ const allCandidates = candidateAgentNames(agentType, taskFile);
64
+ const keep = new Set<string>([target]);
65
+ if (target !== agentType) keep.add(agentType);
66
+ const myBinding = bindings[agentId];
67
+ if (myBinding !== undefined) keep.add(myBinding);
68
+ for (const name of Object.values(bindings)) keep.add(name);
69
+ const out: PrdTaskFile = {};
70
+ for (const name of allCandidates) {
71
+ const entry = taskFile[name];
72
+ if (keep.has(name) && entry) out[name] = entry;
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** The task-PRD (if any) that declares `agent` as a literal key, across every task in the router. */
78
+ function governingTaskFile(agent: string, taskFiles: Record<string, PrdTaskFile | null>): PrdTaskFile | null {
79
+ for (const tf of Object.values(taskFiles)) {
80
+ if (tf && Object.hasOwn(tf, agent)) return tf;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Run the PreToolUse PRD gate.
87
+ * @returns The native outcome (allow bypassing `gate()`, or deny), or `null`
88
+ * to fall through to the ordinary pipeline (module off, or nothing in scope).
89
+ */
90
+ export async function prdPreGate(
91
+ id: string,
92
+ payload: Record<string, unknown>,
93
+ event: NormalizedEvent,
94
+ cwd: string,
95
+ trackFilePath: string,
96
+ now: number,
97
+ ): Promise<HandleOutcome | null> {
98
+ if (!isPrdEnabled(cwd, id)) return null;
99
+ // Canonicalized once: `isPrdScopedPath`/`classifyPrdPath` are pure
100
+ // string/path compares (lot A, by design, no fs) — on macOS, `cwd` (from a
101
+ // spawned process) resolves `/var` to `/private/var` while a caller's own
102
+ // absolute path may still carry the unresolved alias, so root and target
103
+ // must share ONE representation before any comparison (prd-canon.ts).
104
+ const root = canonicalRoot(prdProjectRoot(cwd));
105
+ const homeSeg = harnessHomeSegment(id);
106
+
107
+ if (event.tool === "Bash" && event.command) {
108
+ const redirectHit = shellOutputRedirects(event.command).some((r) => isPrdScopedPath(canonicalFilePath(r.target), root, homeSeg));
109
+ // Beyond `>`/`>>` redirects: cp/mv/tee/sed -i/perl -i/install/dd of= also
110
+ // write a file without any redirect operator (prd-bash-targets.ts, local
111
+ // to this module — never merged into the shared bash-write-redirects.ts/
112
+ // protectedPathGuard, see that file's own header for why).
113
+ const verbHit = extraBashWriteTargets(event.command).some((t) => isPrdScopedPath(canonicalFilePath(t), root, homeSeg));
114
+ if (redirectHit || verbHit) return denyOutcome(id, event, trackFilePath, now, "PRD files must be written via Write/Edit/apply_patch, never Bash.");
115
+ }
116
+
117
+ const files = prdCandidateFiles(event).map(canonicalFilePath);
118
+ if (files.length === 0) return null;
119
+
120
+ const router = await readRouter(root, homeSeg);
121
+ if (router === null) {
122
+ if (!files.some((f) => isPrdScopedPath(f, root, homeSeg))) return null;
123
+ return denyOutcome(id, event, trackFilePath, now, "PRD router is malformed JSON — fix apex/prd.json or unset FUSE_PRD.");
124
+ }
125
+
126
+ const inScope = files
127
+ .map((f) => ({ file: f, kind: classifyPrdPath(f, root, homeSeg, router) }))
128
+ .filter((c): c is { file: string; kind: PrdPathKind } => c.kind !== null && c.kind.kind !== "other");
129
+ if (inScope.length === 0) return null;
130
+
131
+ const identity = resolvePrdIdentity(id, event);
132
+ const track = await loadTrack(trackFilePath);
133
+ // Mutated in-loop (never the loaded track): a 2nd agent-report file in the
134
+ // SAME envelope must see the 1st file's pending bind, or a single agentId
135
+ // could claim two different agent names in one apply_patch batch (each
136
+ // looks free in isolation otherwise).
137
+ let bindings = { ...(track.prdOwners ?? {}) };
138
+ const taskFiles = inScope.some((c) => c.kind.kind === "agentReport") ? await readAllTaskFiles(root, homeSeg, router) : {};
139
+
140
+ const binds: { agentId: string; name: string }[] = [];
141
+ for (const { file, kind } of inScope) {
142
+ let taskFile: PrdTaskFile | null = null;
143
+ if (kind.kind === "agentReport") {
144
+ const governing = governingTaskFile(kind.agent, taskFiles);
145
+ taskFile = governing && identity.agentType !== undefined && identity.agentId !== undefined
146
+ ? narrowForTarget(governing, identity.agentType, kind.agent, identity.agentId, bindings)
147
+ : governing;
148
+ }
149
+ const verdict: PrdOwnershipVerdict = evaluateWriteOwnership({ kind, identity, taskFile, bindings });
150
+ if (verdict.allow === false) return denyOutcome(id, event, trackFilePath, now, `${file}: ${verdict.reason}`);
151
+ if (verdict.allow === true && verdict.bind) {
152
+ binds.push(verdict.bind);
153
+ bindings = { ...bindings, [verdict.bind.agentId]: verdict.bind.name };
154
+ }
155
+ }
156
+
157
+ // A mixed envelope (some candidate files never classified in-scope by
158
+ // classifyPrdPath, e.g. a genuinely unrelated file bundled into the SAME
159
+ // apply_patch) must never ride THIS allow past applyPatchGate/gate() for
160
+ // those other files — only a PURE-PRD envelope (every candidate file
161
+ // in-scope) may short-circuit. A denial above (ownership violation on an
162
+ // in-scope file) still fires regardless of mixing; only the ALLOW path is
163
+ // restricted here, so it never becomes a silent bypass for an unclassified
164
+ // file the PRD gate was never meant to authorize.
165
+ if (inScope.length !== files.length) return null;
166
+
167
+ if (binds.length > 0) {
168
+ await withTrack(trackFilePath, (t) => binds.reduce((acc, b) => recordPrdOwner(acc, b.agentId, b.name), t));
169
+ }
170
+ const mcpDir = projectLayout(cwd).cacheDir;
171
+ return allowOutcome(id, event, payload, mcpDir, cwd, { trackFile: trackFilePath, now });
172
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @module prd-stop-gate
3
+ * Block-once gate for the LEAD's `Stop` event when the router carries an
4
+ * unresolved PRD cross-check violation (design doc §2.5). SYNC, same
5
+ * one-shot idiom as {@link prdSubagentStopGate}, keyed `sessionId:Stop`
6
+ * instead of per-agent. Covers Codex too — its `Stop` already routes to
7
+ * `stopCore` under `scope === "core"`.
8
+ */
9
+ import { dirname } from "node:path";
10
+ import { harnessHomeSegment } from "../../policy/apex-target";
11
+ import {
12
+ hasAnyViolations, isPrdEnabled, prdProjectRoot, readRouterSync, readTaskFileSync,
13
+ type PrdRouter, type PrdTaskFile,
14
+ } from "../../policy/prd";
15
+ import { journalLogPath, readTrackSync } from "../../tracking/track-compact";
16
+ import { trackJournalEnabled } from "../../tracking/store";
17
+ import { appendEvent } from "../../tracking/track-journal";
18
+ import { diffTrackEvents } from "../../tracking/track-diff";
19
+ import { prdAlreadyBlocked, recordPrdStopBlocked, type SessionTrack } from "../../tracking/session-state";
20
+ import { signTrack, writeLastNonce } from "../../tracking/integrity";
21
+ import { withTrackLockSync } from "../../tracking/track-lock-sync";
22
+ import { atomicWrite } from "../../util/json-io";
23
+ import { blockResponse } from "../../adapters/claude";
24
+ import { respond } from "../respond";
25
+
26
+ const REASON = "PRD cross-check found unresolved violation(s): a task/sub-task is marked validated without a matching done report. Run `harness prd status` for details.";
27
+
28
+ /** Sync twin of `readAllTaskFiles` (lot A only exposes the async variant). */
29
+ function readAllTaskFilesSync(root: string, homeSeg: string, router: PrdRouter): Record<string, PrdTaskFile | null> {
30
+ const out: Record<string, PrdTaskFile | null> = {};
31
+ for (const [task, entry] of Object.entries(router)) out[task] = readTaskFileSync(root, homeSeg, entry.prd);
32
+ return out;
33
+ }
34
+
35
+ /**
36
+ * Persist the one-shot block marker on the SAME side `trackJournalEnabled()`
37
+ * reads from. Twin of `prd-subagent-stop.ts`'s own `persistStopBlocked`
38
+ * (intentionally duplicated, not cross-imported — same SOLID file-size
39
+ * discipline as this module's other duplicated helpers, e.g.
40
+ * `readAllTaskFilesSync`). Bug this fixes: the old code always appended to
41
+ * the journal regardless of mode, but `readTrackSync(file,
42
+ * trackJournalEnabled())` never folds the log when `FUSE_TRACK_JOURNAL=0`
43
+ * (track-compact.ts) — so a legacy replay never saw the marker and
44
+ * re-blocked the lead's Stop on every call.
45
+ */
46
+ function persistStopBlocked(trackFilePath: string, track: SessionTrack, key: string, now: number): void {
47
+ if (trackJournalEnabled()) {
48
+ const next = recordPrdStopBlocked(track, key, now);
49
+ for (const ev of diffTrackEvents(track, next, now)) appendEvent(journalLogPath(trackFilePath), ev.field, ev.op, ev.value, ev.ts);
50
+ return;
51
+ }
52
+ withTrackLockSync(dirname(trackFilePath), () => {
53
+ const fresh = readTrackSync(trackFilePath, false); // reload under lock: avoid clobbering a concurrent legacy RMW
54
+ const envelope = signTrack(recordPrdStopBlocked(fresh, key, now));
55
+ atomicWrite(trackFilePath, JSON.stringify(envelope, null, 2));
56
+ writeLastNonce(envelope.nonce);
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Run the PRD lead-Stop block-once gate.
62
+ * @param payload - The raw Stop hook payload.
63
+ * @param cwd - Project root.
64
+ * @param id - Harness target id.
65
+ * @param trackFilePath - The session track file path.
66
+ * @param now - Event clock.
67
+ * @returns The native block stdout (once per session), else `""`.
68
+ */
69
+ export function prdStopGate(payload: Record<string, unknown>, cwd: string, id: string, trackFilePath: string, now: number): string {
70
+ if (!isPrdEnabled(cwd, id)) return "";
71
+ if (id === "cursor") return ""; // `stop` is Cursor's terminal observation event, not gated today (design doc §5 table).
72
+ const root = prdProjectRoot(cwd);
73
+ const homeSeg = harnessHomeSegment(id);
74
+ const router = readRouterSync(root, homeSeg);
75
+ if (!router) return "";
76
+ const track = readTrackSync(trackFilePath, trackJournalEnabled());
77
+ // Two independent violation sources, per the design doc: (a) unresolved
78
+ // `prdViolations` already journaled by the PostToolUse cross-check
79
+ // (§2.2 — `hasAnyViolations` has no track access, so THIS is that half),
80
+ // (b) `hasAnyViolations` itself — a router entry marked `validated` whose
81
+ // task-PRD isn't actually fully validated.
82
+ const trackHasViolations = (track.prdViolations?.length ?? 0) > 0;
83
+ if (!trackHasViolations) {
84
+ const taskFiles = readAllTaskFilesSync(root, homeSeg, router);
85
+ if (!hasAnyViolations(router, taskFiles)) return "";
86
+ }
87
+
88
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : "unknown";
89
+ const key = `${sessionId}:Stop`;
90
+ if (prdAlreadyBlocked(track, key)) return "";
91
+ persistStopBlocked(trackFilePath, track, key, now);
92
+
93
+ // gemini-cli/cline: route through respond() for their own native block
94
+ // envelope (see prd-subagent-stop.ts's identical routing comment for the
95
+ // full rationale — claude-code/codex/hermes stay on blockResponse
96
+ // unchanged, respond() cannot reproduce their byte-identical shape here).
97
+ if (id === "kimi" || id === "gemini-cli" || id === "cline") {
98
+ return respond(id, { kind: "block", title: "PRD cross-check", reason: REASON }, "Stop");
99
+ }
100
+ return blockResponse(REASON);
101
+ }