@fusengine/harness 0.1.92 → 0.1.94

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 (68) 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/hermes/index.mjs +1 -1
  5. package/dist/adapters/kimi/index.mjs +1 -1
  6. package/dist/apex-target-Xc2M32Pl.mjs +48 -0
  7. package/dist/{claude-Ckv2_TgP.mjs → claude-D62hkUfS.mjs} +2 -84
  8. package/dist/cli/bin.mjs +6 -4
  9. package/dist/cli/index.d.mts +69 -1
  10. package/dist/cli/index.mjs +2 -2
  11. package/dist/config/index.mjs +2 -1
  12. package/dist/{dotenv-BLBkBTww.mjs → dotenv-C1LkcfW-.mjs} +1 -26
  13. package/dist/{handle-BF1dZFjY.mjs → handle-CDgBbPRz.mjs} +4648 -3699
  14. package/dist/{hermes-B9-p_3IF.mjs → hermes-ByopGx6C.mjs} +1 -1
  15. package/dist/index.d.mts +1 -1
  16. package/dist/index.mjs +3 -2
  17. package/dist/{kimi-G2wcSh5-.mjs → kimi-C-Oia9q-.mjs} +1 -1
  18. package/dist/policy/index.mjs +1 -1
  19. package/dist/prd-CixxWJIR.mjs +466 -0
  20. package/dist/prd-compact-BE45t8UR.mjs +499 -0
  21. package/dist/runtime/index.d.mts +3 -1
  22. package/dist/runtime/index.mjs +1 -1
  23. package/dist/runtime-io-DuumUeE6.mjs +84 -0
  24. package/dist/{session-state-D5gLr66m.d.mts → session-state-COg7Ej_2.d.mts} +32 -1
  25. package/dist/{skill-path-DVML3zfp.mjs → skill-path-Cz8WFaGu.mjs} +1 -1
  26. package/dist/{store-5-ZPKb0u.mjs → store-BVY6gIYM.mjs} +75 -3
  27. package/dist/tracking/index.d.mts +2 -2
  28. package/dist/tracking/index.mjs +2 -2
  29. package/dist/ttl-Dgwg_QAv.mjs +26 -0
  30. package/dist/{validate-KjZ1X9tH.mjs → validate-Dcjl0LUS.mjs} +4 -49
  31. package/package.json +1 -1
  32. package/src/cli/bin.ts +3 -0
  33. package/src/cli/index.ts +1 -0
  34. package/src/cli/prd/compact.ts +53 -0
  35. package/src/cli/prd/format.ts +23 -0
  36. package/src/cli/prd/index.ts +23 -0
  37. package/src/cli/prd/resolve.ts +88 -0
  38. package/src/cli/prd/shared.ts +80 -0
  39. package/src/cli/prd/status.ts +87 -0
  40. package/src/cli/prd/validate.ts +90 -0
  41. package/src/policy/prd/index.ts +36 -0
  42. package/src/policy/prd/interfaces/types.ts +103 -0
  43. package/src/policy/prd/prd-compact.ts +28 -0
  44. package/src/policy/prd/prd-context.ts +131 -0
  45. package/src/policy/prd/prd-crosscheck.ts +76 -0
  46. package/src/policy/prd/prd-enabled.ts +39 -0
  47. package/src/policy/prd/prd-io.ts +89 -0
  48. package/src/policy/prd/prd-ownership.ts +99 -0
  49. package/src/policy/prd/prd-paths.ts +88 -0
  50. package/src/policy/prd/prd-schema.ts +151 -0
  51. package/src/runtime/handle-post.ts +2 -0
  52. package/src/runtime/handle-pre.ts +11 -0
  53. package/src/runtime/lifecycle/dispatch.ts +18 -5
  54. package/src/runtime/normalize.ts +4 -0
  55. package/src/runtime/prd/index.ts +8 -0
  56. package/src/runtime/prd/prd-bash-targets.ts +199 -0
  57. package/src/runtime/prd/prd-candidate-files.ts +23 -0
  58. package/src/runtime/prd/prd-canon.ts +50 -0
  59. package/src/runtime/prd/prd-identity.ts +32 -0
  60. package/src/runtime/prd/prd-post-check.ts +73 -0
  61. package/src/runtime/prd/prd-pre-gate.ts +172 -0
  62. package/src/runtime/prd/prd-stop-gate.ts +167 -0
  63. package/src/runtime/prd/prd-subagent-context.ts +69 -0
  64. package/src/runtime/prd/prd-subagent-stop.ts +152 -0
  65. package/src/tracking/session-state.ts +46 -0
  66. package/src/tracking/track-diff.ts +3 -0
  67. package/src/tracking/track-journal.ts +10 -1
  68. package/dist/run-DkrzC0gb.mjs +0 -42
@@ -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,167 @@
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
+ canPromoteRouterEntry, hasAnyViolations, isCompacted, isPrdEnabled, prdProjectRoot,
13
+ readRouterSync, readTaskFileSync, subTasksOf, 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, contextResponse } 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
+ /** Task names whose task-PRD is fully validated (every agent, every sub-task) but not yet compacted (`prd-compact.ts`'s own per-agent test, applied task-wide). */
61
+ function compactableTasks(router: PrdRouter, taskFiles: Record<string, PrdTaskFile | null>): string[] {
62
+ const ready: string[] = [];
63
+ for (const task of Object.keys(router)) {
64
+ const taskFile = taskFiles[task];
65
+ if (!taskFile || canPromoteRouterEntry(taskFile)) continue; // missing/unparseable, or already fully compacted
66
+ const fullyValidated = Object.values(taskFile).every((entry) => {
67
+ if (isCompacted(entry)) return true;
68
+ const subs = Object.values(subTasksOf(entry));
69
+ return subs.length > 0 && subs.every((s) => s.status === "validated");
70
+ });
71
+ if (fullyValidated) ready.push(task);
72
+ }
73
+ return ready;
74
+ }
75
+
76
+ /** Names the compactable task(s) and the exact `harness prd compact` invocation (verified against `cli/prd/compact.ts`'s own usage string). */
77
+ function compactHintText(tasks: string[]): string {
78
+ if (tasks.length === 1) return `PRD task "${tasks[0]}" is fully validated and ready to compact. Run \`harness prd compact ${tasks[0]}\`.`;
79
+ return `PRD tasks fully validated and ready to compact: ${tasks.join(", ")}. Run \`harness prd compact <task>\` for each.`;
80
+ }
81
+
82
+ /**
83
+ * Non-blocking "ready to compact" hint. Target-agnostic by design: an id
84
+ * allowlist here would silently go stale the day a target's real Stop wiring
85
+ * changes, so this mirrors the SAME per-target form choice as the block path
86
+ * directly below instead of hardcoding one target — kimi/gemini-cli/cline
87
+ * get their own native `respond()` inform envelope, everyone else gets the
88
+ * `Stop` `additionalContext` channel. Whether this function is ever REACHED
89
+ * for a given id is a wiring question, not a code question: measured live
90
+ * against the real hooks.json this project ships (claude-plugins/
91
+ * codex-plugins/kimi-code-plugins) plus each target's own hook docs — only
92
+ * Codex's Stop wiring is a plain `hook codex core` with no short-circuit;
93
+ * claude-code's and Kimi's own Stop hook always carries `--sound stop`,
94
+ * which exits in `maybePlaySound()` (`src/cli/hook-sound.ts`) before stdin
95
+ * is ever read, and hermes/gemini-cli/cline have no event literally named
96
+ * `Stop` in their own documented hook taxonomy at all — see prd.md's Known
97
+ * limitations for the full measurement. One-shot per session, on a marker
98
+ * key DISTINCT from the block-once key so neither shadows the other.
99
+ * @param id - Harness target id.
100
+ * @param payload - The raw Stop hook payload.
101
+ * @param router - The parsed router.
102
+ * @param taskFiles - Every router task's parsed task-PRD (or `null`).
103
+ * @param track - The current session track.
104
+ * @param trackFilePath - The session track file path.
105
+ * @param now - Event clock.
106
+ * @returns The native non-blocking stdout (once per session), else `""`.
107
+ */
108
+ function compactReminder(
109
+ id: string, payload: Record<string, unknown>, router: PrdRouter,
110
+ taskFiles: Record<string, PrdTaskFile | null>, track: SessionTrack, trackFilePath: string, now: number,
111
+ ): string {
112
+ const ready = compactableTasks(router, taskFiles);
113
+ if (ready.length === 0) return "";
114
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : "unknown";
115
+ const key = `${sessionId}:StopCompactHint`;
116
+ if (prdAlreadyBlocked(track, key)) return "";
117
+ persistStopBlocked(trackFilePath, track, key, now);
118
+ const text = compactHintText(ready);
119
+ if (id === "kimi" || id === "gemini-cli" || id === "cline") {
120
+ return respond(id, { kind: "inform", title: "PRD compact", reason: text }, "Stop");
121
+ }
122
+ return contextResponse("Stop", text);
123
+ }
124
+
125
+ /**
126
+ * Run the PRD lead-Stop block-once gate.
127
+ * @param payload - The raw Stop hook payload.
128
+ * @param cwd - Project root.
129
+ * @param id - Harness target id.
130
+ * @param trackFilePath - The session track file path.
131
+ * @param now - Event clock.
132
+ * @returns The native block stdout (once per session); else the compact
133
+ * hint (once per session, whichever ids' real Stop wiring reaches it); else `""`.
134
+ */
135
+ export function prdStopGate(payload: Record<string, unknown>, cwd: string, id: string, trackFilePath: string, now: number): string {
136
+ if (!isPrdEnabled(cwd, id)) return "";
137
+ if (id === "cursor") return ""; // `stop` is Cursor's terminal observation event, not gated today (design doc §5 table).
138
+ const root = prdProjectRoot(cwd);
139
+ const homeSeg = harnessHomeSegment(id);
140
+ const router = readRouterSync(root, homeSeg);
141
+ if (!router) return "";
142
+ const track = readTrackSync(trackFilePath, trackJournalEnabled());
143
+ // Two independent violation sources, per the design doc: (a) unresolved
144
+ // `prdViolations` already journaled by the PostToolUse cross-check
145
+ // (§2.2 — `hasAnyViolations` has no track access, so THIS is that half),
146
+ // (b) `hasAnyViolations` itself — a router entry marked `validated` whose
147
+ // task-PRD isn't actually fully validated.
148
+ const trackHasViolations = (track.prdViolations?.length ?? 0) > 0;
149
+ if (!trackHasViolations) {
150
+ const taskFiles = readAllTaskFilesSync(root, homeSeg, router);
151
+ if (!hasAnyViolations(router, taskFiles)) return compactReminder(id, payload, router, taskFiles, track, trackFilePath, now);
152
+ }
153
+
154
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : "unknown";
155
+ const key = `${sessionId}:Stop`;
156
+ if (prdAlreadyBlocked(track, key)) return "";
157
+ persistStopBlocked(trackFilePath, track, key, now);
158
+
159
+ // gemini-cli/cline: route through respond() for their own native block
160
+ // envelope (see prd-subagent-stop.ts's identical routing comment for the
161
+ // full rationale — claude-code/codex/hermes stay on blockResponse
162
+ // unchanged, respond() cannot reproduce their byte-identical shape here).
163
+ if (id === "kimi" || id === "gemini-cli" || id === "cline") {
164
+ return respond(id, { kind: "block", title: "PRD cross-check", reason: REASON }, "Stop");
165
+ }
166
+ return blockResponse(REASON);
167
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @module prd-subagent-context
3
+ * SubagentStart injection (design doc §2.3) — the sub-agent's own PRD slice,
4
+ * never the whole task. Kept SYNCHRONOUS on purpose: `dispatchLifecycle` is
5
+ * called synchronously (no `await`) from `lifecycle-bridge.ts`/`handle.ts`,
6
+ * and making that chain async for one new injector would touch 3 unrelated
7
+ * files purely for signature plumbing — `prd-io.ts`'s sync twins
8
+ * (`readRouterSync`/`readTaskFileSync`) exist precisely so this call site
9
+ * needs zero signature changes anywhere else.
10
+ */
11
+ import { contextResponse } from "../../adapters/claude";
12
+ import { harnessHomeSegment } from "../../policy/apex-target";
13
+ import {
14
+ agentSlices, isPrdEnabled, prdProjectRoot, readRouterSync, readTaskFileSync, renderAgentSliceMarkdown,
15
+ type PrdSubagentSlice,
16
+ } from "../../policy/prd";
17
+ import { respond } from "../respond";
18
+
19
+ /** Compliance reminder appended after the rendered slice(s) — the 3 rules a sub-agent must keep in mind. */
20
+ const RULES = [
21
+ "1. Write ONLY the files listed in your slice above.",
22
+ "2. Report completion to YOUR OWN agent-report file above — never another agent's.",
23
+ "3. Do not mark a sub-task done until the work is actually finished; the coordinator validates from your report.",
24
+ ].join("\n");
25
+
26
+ /** `agent_type`, same fallback `dispatch-aipilot.ts`'s `agentTypeOf` uses (duplicated here per SOLID file-size discipline, not cross-imported). */
27
+ function agentTypeOf(payload: Record<string, unknown>): string {
28
+ return String(payload.agent_type ?? payload.subagent_type ?? "");
29
+ }
30
+
31
+ /**
32
+ * Build the SubagentStart PRD context injection for this agent, or `""` when
33
+ * the module is off, the caller is unnamed, or nothing in the router
34
+ * concerns it.
35
+ * @param payload - The raw SubagentStart hook payload.
36
+ * @param cwd - Project root.
37
+ * @param id - Harness target id.
38
+ * @returns The native `additionalContext` hook stdout, or `""`.
39
+ */
40
+ export function prdSubagentContext(payload: Record<string, unknown>, cwd: string, id: string): string {
41
+ if (!isPrdEnabled(cwd, id)) return "";
42
+ const agentType = agentTypeOf(payload);
43
+ if (!agentType) return "";
44
+ const root = prdProjectRoot(cwd);
45
+ const homeSeg = harnessHomeSegment(id);
46
+ const router = readRouterSync(root, homeSeg);
47
+ if (!router) return "";
48
+
49
+ const slices: PrdSubagentSlice[] = [];
50
+ for (const [task, entry] of Object.entries(router)) {
51
+ const taskFile = readTaskFileSync(root, homeSeg, entry.prd);
52
+ if (!taskFile) continue;
53
+ slices.push(...agentSlices(taskFile, agentType, task));
54
+ }
55
+ if (slices.length === 0) return "";
56
+
57
+ const text = `${renderAgentSliceMarkdown(slices)}\n\n### Rules\n${RULES}`;
58
+ // gemini-cli/cline don't understand Claude's hookSpecificOutput.additionalContext
59
+ // envelope: gemini-cli's native shape has no hookEventName field and cline's
60
+ // is `{contextModification}` entirely — route them through respond()'s
61
+ // already-correct "inform" branches. claude-code/codex/kimi/cursor/hermes
62
+ // stay on contextResponse unchanged: respond()'s "inform" kind re-wraps the
63
+ // text through formatPrompt (adds a "[NOTE] title" line), which would NOT
64
+ // reproduce today's byte-identical raw-markdown output.
65
+ if (id === "gemini-cli" || id === "cline") {
66
+ return respond(id, { kind: "inform", title: "PRD assignment", reason: text }, "SubagentStart");
67
+ }
68
+ return contextResponse("SubagentStart", text);
69
+ }