@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,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
|
+
}
|