@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.
- package/README.md +17 -0
- package/dist/adapters/claude/index.mjs +1 -1
- package/dist/adapters/codex/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/{claude-Ckv2_TgP.mjs → claude-D62hkUfS.mjs} +2 -84
- package/dist/cli/bin.mjs +6 -4
- 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-BF1dZFjY.mjs → handle-CDgBbPRz.mjs} +4648 -3699
- 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/policy/index.mjs +1 -1
- package/dist/prd-CixxWJIR.mjs +466 -0
- package/dist/prd-compact-BE45t8UR.mjs +499 -0
- package/dist/runtime/index.d.mts +3 -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/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/lifecycle/dispatch.ts +18 -5
- 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 +167 -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,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
|
+
}
|