@fusengine/harness 0.1.92 → 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/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 +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-B-g9KL_9.mjs} +4583 -3695
- 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-PvK7PKWS.mjs +494 -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 +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,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin fail-closed wrappers over `util/json-io.ts` + the `prd-schema.ts`
|
|
3
|
+
* parsers. Both async (CLI, PostToolUse) and sync (SubagentStart/Stop, which
|
|
4
|
+
* must not float async work) variants. The only two files in this module
|
|
5
|
+
* that touch disk (besides `prd-enabled.ts`).
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { readJsonFile, writeJsonFile } from "../../util/json-io";
|
|
9
|
+
import { prdAgentReportPath, prdRouterPath, prdTaskPath } from "./prd-paths";
|
|
10
|
+
import { parseAgentReportFile, parseRouter, parseTaskFile } from "./prd-schema";
|
|
11
|
+
import type { PrdAgentReportFile, PrdRouter, PrdTaskFile } from "./interfaces/types";
|
|
12
|
+
|
|
13
|
+
function readJsonSync(path: string): unknown {
|
|
14
|
+
try {
|
|
15
|
+
if (!existsSync(path)) return null;
|
|
16
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Reads the router file; `null` when missing or malformed. */
|
|
23
|
+
export async function readRouter(root: string, homeSeg: string): Promise<PrdRouter | null> {
|
|
24
|
+
const raw = await readJsonFile<unknown>(prdRouterPath(root, homeSeg));
|
|
25
|
+
return raw === null ? null : parseRouter(raw);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Sync twin of {@link readRouter}, for SubagentStart/Stop call sites. */
|
|
29
|
+
export function readRouterSync(root: string, homeSeg: string): PrdRouter | null {
|
|
30
|
+
const raw = readJsonSync(prdRouterPath(root, homeSeg));
|
|
31
|
+
return raw === null ? null : parseRouter(raw);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Reads a task-PRD file; `null` when missing or malformed. */
|
|
35
|
+
export async function readTaskFile(root: string, homeSeg: string, relPrd: string): Promise<PrdTaskFile | null> {
|
|
36
|
+
const raw = await readJsonFile<unknown>(prdTaskPath(root, homeSeg, relPrd));
|
|
37
|
+
return raw === null ? null : parseTaskFile(raw);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Sync twin of {@link readTaskFile}. */
|
|
41
|
+
export function readTaskFileSync(root: string, homeSeg: string, relPrd: string): PrdTaskFile | null {
|
|
42
|
+
const raw = readJsonSync(prdTaskPath(root, homeSeg, relPrd));
|
|
43
|
+
return raw === null ? null : parseTaskFile(raw);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Reads every task-PRD file named in `router`, keyed by task name (`null` per unreadable/malformed entry). */
|
|
47
|
+
export async function readAllTaskFiles(
|
|
48
|
+
root: string,
|
|
49
|
+
homeSeg: string,
|
|
50
|
+
router: PrdRouter,
|
|
51
|
+
): Promise<Record<string, PrdTaskFile | null>> {
|
|
52
|
+
const out: Record<string, PrdTaskFile | null> = {};
|
|
53
|
+
for (const [task, entry] of Object.entries(router)) {
|
|
54
|
+
out[task] = await readTaskFile(root, homeSeg, entry.prd);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Reads an agent's own report file; `null` when missing or malformed. */
|
|
60
|
+
export async function readAgentReport(root: string, homeSeg: string, agent: string): Promise<PrdAgentReportFile | null> {
|
|
61
|
+
const raw = await readJsonFile<unknown>(prdAgentReportPath(root, homeSeg, agent));
|
|
62
|
+
return raw === null ? null : parseAgentReportFile(raw);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Sync twin of {@link readAgentReport}. */
|
|
66
|
+
export function readAgentReportSync(root: string, homeSeg: string, agent: string): PrdAgentReportFile | null {
|
|
67
|
+
const raw = readJsonSync(prdAgentReportPath(root, homeSeg, agent));
|
|
68
|
+
return raw === null ? null : parseAgentReportFile(raw);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Atomically writes the router file. */
|
|
72
|
+
export async function writeRouter(root: string, homeSeg: string, router: PrdRouter): Promise<void> {
|
|
73
|
+
await writeJsonFile(prdRouterPath(root, homeSeg), router);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Atomically writes a task-PRD file. */
|
|
77
|
+
export async function writeTaskFile(root: string, homeSeg: string, relPrd: string, file: PrdTaskFile): Promise<void> {
|
|
78
|
+
await writeJsonFile(prdTaskPath(root, homeSeg, relPrd), file);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Atomically writes an agent's own report file. */
|
|
82
|
+
export async function writeAgentReport(
|
|
83
|
+
root: string,
|
|
84
|
+
homeSeg: string,
|
|
85
|
+
agent: string,
|
|
86
|
+
file: PrdAgentReportFile,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
await writeJsonFile(prdAgentReportPath(root, homeSeg, agent), file);
|
|
89
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PreToolUse ownership decision. Everything it needs is passed in — no
|
|
3
|
+
* I/O. Pure functions over already-loaded router/task-PRD/bindings data.
|
|
4
|
+
*/
|
|
5
|
+
import type { PrdIdentity, PrdOwnershipVerdict, PrdPathKind, PrdTaskFile } from "./interfaces/types";
|
|
6
|
+
|
|
7
|
+
/** True when `name === agentType`, or `"<agentType>-<n>"` with `n >= 2`. */
|
|
8
|
+
export function matchesAgentName(name: string, agentType: string): boolean {
|
|
9
|
+
if (name === agentType) return true;
|
|
10
|
+
if (!name.startsWith(agentType)) return false;
|
|
11
|
+
const suffix = name.slice(agentType.length);
|
|
12
|
+
if (!suffix.startsWith("-")) return false;
|
|
13
|
+
const numPart = suffix.slice(1);
|
|
14
|
+
return /^\d+$/.test(numPart) && Number(numPart) >= 2;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Agent names in `taskFile` whose name matches `agentType` (base or `-n` suffixed). */
|
|
18
|
+
export function candidateAgentNames(agentType: string, taskFile: PrdTaskFile): string[] {
|
|
19
|
+
return Object.keys(taskFile).filter((name) => matchesAgentName(name, agentType));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* agentId -> bound name resolution. `bindings` is the journal's `prdOwners`
|
|
24
|
+
* map (agentId -> name), read-only here. Returns the candidate already
|
|
25
|
+
* bound to this agentId, or the SOLE still-unbound candidate (free to bind
|
|
26
|
+
* now), or `null` (ambiguous: >1 free candidate, or 0 candidates at all).
|
|
27
|
+
*/
|
|
28
|
+
export function resolveOwnerBinding(
|
|
29
|
+
candidates: string[],
|
|
30
|
+
agentId: string,
|
|
31
|
+
bindings: Record<string, string>,
|
|
32
|
+
): { name: string; alreadyBound: boolean } | null {
|
|
33
|
+
const boundToMe = bindings[agentId];
|
|
34
|
+
if (boundToMe !== undefined && candidates.includes(boundToMe)) {
|
|
35
|
+
return { name: boundToMe, alreadyBound: true };
|
|
36
|
+
}
|
|
37
|
+
const takenByOthers = new Set(
|
|
38
|
+
Object.entries(bindings).filter(([id]) => id !== agentId).map(([, name]) => name),
|
|
39
|
+
);
|
|
40
|
+
const free = candidates.filter((c) => !takenByOthers.has(c));
|
|
41
|
+
if (free.length !== 1) return null;
|
|
42
|
+
const [name] = free;
|
|
43
|
+
return name === undefined ? null : { name, alreadyBound: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const COORDINATOR_ONLY_LABEL: Record<"router" | "task" | "docs", string> = {
|
|
47
|
+
router: "router",
|
|
48
|
+
task: "task PRD",
|
|
49
|
+
docs: "docs",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function evaluateCoordinatorOnly(kind: "router" | "task" | "docs", identity: PrdIdentity): PrdOwnershipVerdict {
|
|
53
|
+
if (identity.lead === true) return { allow: true };
|
|
54
|
+
if (identity.lead === "unknown") return { allow: "advisory" };
|
|
55
|
+
return { allow: false, reason: `${COORDINATOR_ONLY_LABEL[kind]} is coordinator-only` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function evaluateAgentReport(
|
|
59
|
+
agent: string,
|
|
60
|
+
identity: PrdIdentity,
|
|
61
|
+
taskFile: PrdTaskFile | null,
|
|
62
|
+
bindings: Record<string, string>,
|
|
63
|
+
): PrdOwnershipVerdict {
|
|
64
|
+
if (identity.lead === true) return { allow: false, reason: "agent report is not the coordinator's to write" };
|
|
65
|
+
if (identity.lead === "unknown") return { allow: "advisory" };
|
|
66
|
+
if (identity.agentType === undefined || identity.agentId === undefined) {
|
|
67
|
+
return { allow: false, reason: "unidentifiable agent_type — cannot verify ownership" };
|
|
68
|
+
}
|
|
69
|
+
const candidates = candidateAgentNames(identity.agentType, taskFile ?? {});
|
|
70
|
+
const resolved = resolveOwnerBinding(candidates, identity.agentId, bindings);
|
|
71
|
+
if (!resolved || resolved.name !== agent) {
|
|
72
|
+
return { allow: false, reason: "name doesn't match your agent_type, or already bound to another agent" };
|
|
73
|
+
}
|
|
74
|
+
return resolved.alreadyBound
|
|
75
|
+
? { allow: true }
|
|
76
|
+
: { allow: true, bind: { agentId: identity.agentId, name: resolved.name } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Top-level PreToolUse verdict for ONE file path already known to be in
|
|
81
|
+
* scope (`classifyPrdPath` returned non-`"other"`/non-`null` upstream).
|
|
82
|
+
*/
|
|
83
|
+
export function evaluateWriteOwnership(input: {
|
|
84
|
+
kind: PrdPathKind;
|
|
85
|
+
identity: PrdIdentity;
|
|
86
|
+
taskFile: PrdTaskFile | null;
|
|
87
|
+
bindings: Record<string, string>;
|
|
88
|
+
}): PrdOwnershipVerdict {
|
|
89
|
+
const { kind, identity, taskFile, bindings } = input;
|
|
90
|
+
|
|
91
|
+
if (kind.kind === "router" || kind.kind === "task" || kind.kind === "docs") {
|
|
92
|
+
return evaluateCoordinatorOnly(kind.kind, identity);
|
|
93
|
+
}
|
|
94
|
+
if (kind.kind === "agentReport") {
|
|
95
|
+
return evaluateAgentReport(kind.agent, identity, taskFile, bindings);
|
|
96
|
+
}
|
|
97
|
+
if (identity.lead === "unknown") return { allow: "advisory" };
|
|
98
|
+
return { allow: false, reason: "not a recognized PRD file" };
|
|
99
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure path builders and classifiers for the PRD file tree
|
|
3
|
+
* (`<root>/<homeSeg>/apex/prd*`). No fs access — string/path compare only.
|
|
4
|
+
*/
|
|
5
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import type { PrdPathKind, PrdRouter } from "./interfaces/types";
|
|
7
|
+
|
|
8
|
+
const AGENT_REPORT_SUFFIX = "-prd.json";
|
|
9
|
+
const DOCS_SUFFIX = ".md";
|
|
10
|
+
|
|
11
|
+
function apexDir(root: string, homeSeg: string): string {
|
|
12
|
+
return join(root, homeSeg, "apex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Absolute path to the router (`<root>/<homeSeg>/apex/prd.json`). */
|
|
16
|
+
export function prdRouterPath(root: string, homeSeg: string): string {
|
|
17
|
+
return join(apexDir(root, homeSeg), "prd.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Absolute path to the PRD directory (`<root>/<homeSeg>/apex/prd`). */
|
|
21
|
+
export function prdDir(root: string, homeSeg: string): string {
|
|
22
|
+
return join(apexDir(root, homeSeg), "prd");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Absolute path to a task-PRD file, given the router entry's `prd` field. */
|
|
26
|
+
export function prdTaskPath(root: string, homeSeg: string, relPrd: string): string {
|
|
27
|
+
return join(apexDir(root, homeSeg), relPrd);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Absolute path to an agent's own report file (`prd/agents/<agent>-prd.json`). */
|
|
31
|
+
export function prdAgentReportPath(root: string, homeSeg: string, agent: string): string {
|
|
32
|
+
return join(prdDir(root, homeSeg), "agents", `${agent}${AGENT_REPORT_SUFFIX}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Absolute path to a task's free-form doc (`prd/docs/<task>.md`). */
|
|
36
|
+
export function prdDocsPath(root: string, homeSeg: string, task: string): string {
|
|
37
|
+
return join(prdDir(root, homeSeg), "docs", `${task}${DOCS_SUFFIX}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveAgainstRoot(filePath: string, root: string): string {
|
|
41
|
+
return isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True when `filePath` (absolute or root-relative) resolves under
|
|
46
|
+
* `<root>/<homeSeg>/apex/prd/`, or is exactly the router itself. Normalizes
|
|
47
|
+
* via `path.resolve`/`relative`; a `..` that escapes the PRD dir is rejected.
|
|
48
|
+
*/
|
|
49
|
+
export function isPrdScopedPath(filePath: string, root: string, homeSeg: string): boolean {
|
|
50
|
+
const abs = resolveAgainstRoot(filePath, root);
|
|
51
|
+
if (abs === resolve(prdRouterPath(root, homeSeg))) return true;
|
|
52
|
+
const rel = relative(resolve(prdDir(root, homeSeg)), abs);
|
|
53
|
+
return rel !== "" && rel !== "." && !rel.startsWith("..") && !isAbsolute(rel);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Classifies an in-scope PRD path into one of the 4 file kinds (router,
|
|
58
|
+
* task, agentReport, docs), or `"other"` when it is textually under
|
|
59
|
+
* `apex/prd/` but matches none of the router-declared task files. `null`
|
|
60
|
+
* when the path is not in scope at all (see {@link isPrdScopedPath}).
|
|
61
|
+
*/
|
|
62
|
+
export function classifyPrdPath(
|
|
63
|
+
filePath: string,
|
|
64
|
+
root: string,
|
|
65
|
+
homeSeg: string,
|
|
66
|
+
router: PrdRouter | null,
|
|
67
|
+
): PrdPathKind | null {
|
|
68
|
+
if (!isPrdScopedPath(filePath, root, homeSeg)) return null;
|
|
69
|
+
const abs = resolveAgainstRoot(filePath, root);
|
|
70
|
+
if (abs === resolve(prdRouterPath(root, homeSeg))) return { kind: "router" };
|
|
71
|
+
|
|
72
|
+
const dir = resolve(prdDir(root, homeSeg));
|
|
73
|
+
const parts = relative(dir, abs).split(sep);
|
|
74
|
+
const [first, second] = parts;
|
|
75
|
+
|
|
76
|
+
if (parts.length === 2 && first === "agents" && second?.endsWith(AGENT_REPORT_SUFFIX)) {
|
|
77
|
+
return { kind: "agentReport", agent: second.slice(0, -AGENT_REPORT_SUFFIX.length) };
|
|
78
|
+
}
|
|
79
|
+
if (parts.length === 2 && first === "docs" && second?.endsWith(DOCS_SUFFIX)) {
|
|
80
|
+
return { kind: "docs", task: second.slice(0, -DOCS_SUFFIX.length) };
|
|
81
|
+
}
|
|
82
|
+
if (router) {
|
|
83
|
+
for (const [task, entry] of Object.entries(router)) {
|
|
84
|
+
if (resolve(prdTaskPath(root, homeSeg, entry.prd)) === abs) return { kind: "task", task };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { kind: "other" };
|
|
88
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed parsers (malformed input -> `null`, never throw) and small
|
|
3
|
+
* immutable builders for the PRD JSON contract. Pure — no fs.
|
|
4
|
+
*/
|
|
5
|
+
import type {
|
|
6
|
+
PrdAgentEntryCompacted, PrdAgentReportFile, PrdRouter, PrdRouterEntry, PrdRouterStatus,
|
|
7
|
+
PrdSubTask, PrdTaskAgentEntry, PrdTaskFile,
|
|
8
|
+
} from "./interfaces/types";
|
|
9
|
+
|
|
10
|
+
const ROUTER_STATUSES: readonly PrdRouterStatus[] = ["assigned", "in-progress", "validated"];
|
|
11
|
+
|
|
12
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
13
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isStringArray(v: unknown): v is string[] {
|
|
17
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseRouterEntry(v: unknown): PrdRouterEntry | null {
|
|
21
|
+
if (!isRecord(v)) return null;
|
|
22
|
+
if (typeof v.prd !== "string") return null;
|
|
23
|
+
if (typeof v.status !== "string" || !ROUTER_STATUSES.includes(v.status as PrdRouterStatus)) return null;
|
|
24
|
+
const validatedAt = v["validated-at"];
|
|
25
|
+
if (validatedAt !== undefined && typeof validatedAt !== "string") return null;
|
|
26
|
+
return validatedAt === undefined
|
|
27
|
+
? { prd: v.prd, status: v.status as PrdRouterStatus }
|
|
28
|
+
: { prd: v.prd, status: v.status as PrdRouterStatus, "validated-at": validatedAt };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parses the router file (`prd.json`); `null` on any malformed shape. */
|
|
32
|
+
export function parseRouter(raw: unknown): PrdRouter | null {
|
|
33
|
+
if (!isRecord(raw)) return null;
|
|
34
|
+
const out: PrdRouter = {};
|
|
35
|
+
for (const [task, v] of Object.entries(raw)) {
|
|
36
|
+
const entry = parseRouterEntry(v);
|
|
37
|
+
if (!entry) return null;
|
|
38
|
+
out[task] = entry;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseSubTask(v: unknown): PrdSubTask | null {
|
|
44
|
+
if (!isRecord(v) || typeof v.status !== "string") return null;
|
|
45
|
+
if (v.status !== "assigned" && v.status !== "validated") return null;
|
|
46
|
+
const validatedAt = v["validated-at"];
|
|
47
|
+
if (validatedAt !== undefined && typeof validatedAt !== "string") return null;
|
|
48
|
+
return validatedAt === undefined ? { status: v.status } : { status: v.status, "validated-at": validatedAt };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseAgentEntry(v: unknown): PrdTaskAgentEntry | null {
|
|
52
|
+
if (!isRecord(v) || !isStringArray(v.files)) return null;
|
|
53
|
+
if (v.status === "validated") {
|
|
54
|
+
if (typeof v["validated-at"] !== "string") return null;
|
|
55
|
+
return { status: "validated", files: v.files, "validated-at": v["validated-at"] };
|
|
56
|
+
}
|
|
57
|
+
if (!isRecord(v["sub-tasks"])) return null;
|
|
58
|
+
const subTasks: Record<string, PrdSubTask> = {};
|
|
59
|
+
for (const [sub, sv] of Object.entries(v["sub-tasks"])) {
|
|
60
|
+
const parsed = parseSubTask(sv);
|
|
61
|
+
if (!parsed) return null;
|
|
62
|
+
subTasks[sub] = parsed;
|
|
63
|
+
}
|
|
64
|
+
return { files: v.files, "sub-tasks": subTasks };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Parses a task-PRD file; `null` on any malformed shape. */
|
|
68
|
+
export function parseTaskFile(raw: unknown): PrdTaskFile | null {
|
|
69
|
+
if (!isRecord(raw)) return null;
|
|
70
|
+
const out: PrdTaskFile = {};
|
|
71
|
+
for (const [agent, v] of Object.entries(raw)) {
|
|
72
|
+
const entry = parseAgentEntry(v);
|
|
73
|
+
if (!entry) return null;
|
|
74
|
+
out[agent] = entry;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Parses an agent's own report file; `null` on any malformed shape. */
|
|
80
|
+
export function parseAgentReportFile(raw: unknown): PrdAgentReportFile | null {
|
|
81
|
+
if (!isRecord(raw)) return null;
|
|
82
|
+
const out: PrdAgentReportFile = {};
|
|
83
|
+
for (const [task, subs] of Object.entries(raw)) {
|
|
84
|
+
if (!isRecord(subs)) return null;
|
|
85
|
+
const parsedSubs: Record<string, PrdAgentReportFile[string][string]> = {};
|
|
86
|
+
for (const [sub, v] of Object.entries(subs)) {
|
|
87
|
+
if (!isRecord(v) || v.status !== "done" || !isStringArray(v.modified) || !isStringArray(v.unchanged)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const doneAt = v["done-at"];
|
|
91
|
+
if (doneAt !== undefined && typeof doneAt !== "string") return null;
|
|
92
|
+
parsedSubs[sub] = doneAt === undefined
|
|
93
|
+
? { status: "done", modified: v.modified, unchanged: v.unchanged }
|
|
94
|
+
: { status: "done", modified: v.modified, unchanged: v.unchanged, "done-at": doneAt };
|
|
95
|
+
}
|
|
96
|
+
out[task] = parsedSubs;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** True when `e` is the post-compaction shape. */
|
|
102
|
+
export function isCompacted(e: PrdTaskAgentEntry): e is PrdAgentEntryCompacted {
|
|
103
|
+
return "status" in e && e.status === "validated" && !("sub-tasks" in e);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Sub-tasks of an agent entry; `{}` when already compacted. */
|
|
107
|
+
export function subTasksOf(e: PrdTaskAgentEntry): Record<string, PrdSubTask> {
|
|
108
|
+
return isCompacted(e) ? {} : e["sub-tasks"];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Files owned by an agent entry, expanded or compacted. */
|
|
112
|
+
export function filesOf(e: PrdTaskAgentEntry): string[] {
|
|
113
|
+
return e.files;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `["needs >= 2 agents", ...]` — empty when the task PRD satisfies the contract. */
|
|
117
|
+
export function validateTaskFileInvariant(taskFile: PrdTaskFile): string[] {
|
|
118
|
+
const errors: string[] = [];
|
|
119
|
+
if (Object.keys(taskFile).length < 2) errors.push("needs >= 2 agents");
|
|
120
|
+
return errors;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Returns a new router with `task`'s status (and optional `validated-at`) updated. */
|
|
124
|
+
export function withRouterStatus(
|
|
125
|
+
router: PrdRouter,
|
|
126
|
+
task: string,
|
|
127
|
+
status: PrdRouterStatus,
|
|
128
|
+
at?: string,
|
|
129
|
+
): PrdRouter {
|
|
130
|
+
const existing = router[task];
|
|
131
|
+
if (!existing) return router;
|
|
132
|
+
const entry: PrdRouterEntry = at === undefined
|
|
133
|
+
? { prd: existing.prd, status }
|
|
134
|
+
: { prd: existing.prd, status, "validated-at": at };
|
|
135
|
+
return { ...router, [task]: entry };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Returns a new task-PRD file with one agent's sub-task flipped to `validated`. */
|
|
139
|
+
export function withSubTaskValidated(
|
|
140
|
+
taskFile: PrdTaskFile,
|
|
141
|
+
agent: string,
|
|
142
|
+
sub: string,
|
|
143
|
+
at: string,
|
|
144
|
+
): PrdTaskFile {
|
|
145
|
+
const entry = taskFile[agent];
|
|
146
|
+
if (!entry || isCompacted(entry)) return taskFile;
|
|
147
|
+
const subTask = entry["sub-tasks"][sub];
|
|
148
|
+
if (!subTask) return taskFile;
|
|
149
|
+
const nextSubTasks = { ...entry["sub-tasks"], [sub]: { status: "validated" as const, "validated-at": at } };
|
|
150
|
+
return { ...taskFile, [agent]: { files: entry.files, "sub-tasks": nextSubTasks } };
|
|
151
|
+
}
|
|
@@ -14,6 +14,7 @@ import { recordCodexPostFailure } from "../tracking/codex-post-failure";
|
|
|
14
14
|
import { defaultStateDir } from "./paths";
|
|
15
15
|
import { fanOutFiles, firstFileMatch } from "./post-fanout";
|
|
16
16
|
import { postOutcome } from "./post-outcome";
|
|
17
|
+
import { prdPostCheck } from "./prd";
|
|
17
18
|
import type { PreContext } from "./handle-pre";
|
|
18
19
|
import type { HandleOutcome } from "./handle";
|
|
19
20
|
|
|
@@ -75,6 +76,7 @@ export async function handlePost(ctx: PreContext): Promise<HandleOutcome> {
|
|
|
75
76
|
// tracking, validation, post-edit context, and notices.
|
|
76
77
|
const files = fanOutFiles(event);
|
|
77
78
|
for (const f of files) postTrackingSideEffects(opts.scope ?? "core", f, f.input, opts.now, payload, opts.cwd);
|
|
79
|
+
await prdPostCheck(id, event, opts.cwd, file, opts.now); // PRD cross-check — advisory only, never returns stdout
|
|
78
80
|
const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
|
|
79
81
|
if (seoDeny && !cursorAfterFileEdit) return { stdout: seoDeny, exit: 0 };
|
|
80
82
|
if (opts.scope === "solid") {
|
|
@@ -16,6 +16,7 @@ import { applyPatchGate } from "./apply-patch-gate";
|
|
|
16
16
|
import { isBypassPermissions } from "../adapters/codex/permission-mode";
|
|
17
17
|
import { evaluate } from "../policy/evaluate";
|
|
18
18
|
import { confirmGate } from "./confirm/confirm-gate";
|
|
19
|
+
import { prdPreGate } from "./prd";
|
|
19
20
|
import type { HandleOptions, HandleOutcome } from "./handle";
|
|
20
21
|
|
|
21
22
|
/** Context the PreToolUse pipeline needs (resolved once by {@link handleHook}). */
|
|
@@ -85,6 +86,16 @@ export async function handlePre(ctx: PreContext): Promise<HandleOutcome> {
|
|
|
85
86
|
if (taskCtx) return { stdout: taskCtx, exit: 0 };
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
// PRD (task/agent ownership coordination): inert unless FUSE_PRD=1 AND a
|
|
90
|
+
// router is present — see runtime/prd/prd-pre-gate.ts. Runs BEFORE the
|
|
91
|
+
// apply_patch gate below: `applyPatchGate` runs `evaluate()` per file, whose
|
|
92
|
+
// `runGuards()` already includes `protectedPathGuard` — which unconditionally
|
|
93
|
+
// blocks every `.claude/apex/` path, PRD included. A PRD write this gate just
|
|
94
|
+
// authorized must short-circuit past both `applyPatchGate` and the gate chain
|
|
95
|
+
// further down, never reach either.
|
|
96
|
+
const prdOutcome = await prdPreGate(id, payload, event, opts.cwd, file, opts.now);
|
|
97
|
+
if (prdOutcome) return prdOutcome;
|
|
98
|
+
|
|
88
99
|
// Codex `apply_patch`: normalize.ts fanned the freeform patch into per-file
|
|
89
100
|
// changes. OR their static verdicts — one violating hunk blocks the whole
|
|
90
101
|
// envelope. `event.files` is undefined for every other tool/harness.
|
|
@@ -17,6 +17,10 @@ import { cartoSessionStart } from "./cartographer/session-start";
|
|
|
17
17
|
import { dispatchLessons } from "./lessons/dispatch";
|
|
18
18
|
import { withSnapshot } from "./snapshot";
|
|
19
19
|
import { stopCore } from "./stop-core";
|
|
20
|
+
import { prdSubagentContext, prdSubagentStopGate, prdStopGate } from "../prd";
|
|
21
|
+
import { joinContextResponses } from "../../policy/prd";
|
|
22
|
+
import { sanitizeSessionId } from "../home-state";
|
|
23
|
+
import { defaultStateDir, trackFile } from "../paths";
|
|
20
24
|
|
|
21
25
|
/** Which plugin's hooks.json invoked the harness (selects SessionStart behavior). */
|
|
22
26
|
export type PluginScope = "core" | "solid" | "rules" | "carto" | "security" | "changelog" | "aipilot" | "lessons" | "seo" | "memory" | "tailwindcss";
|
|
@@ -70,11 +74,14 @@ export function dispatchLifecycle(input: LifecycleInput): string | null {
|
|
|
70
74
|
if (input.scope === "rules") return injectRules(resolveRulesRoot(input.id ?? "claude-code", input.cwd), input.event, input.id ?? "claude-code");
|
|
71
75
|
if (input.scope === "aipilot") return "";
|
|
72
76
|
if (input.scope === "lessons") return dispatchLessons("SubagentStart", input.payload, input.cwd, input.now, input.id ?? "claude-code");
|
|
73
|
-
return subagentCacheContext(input.payload.session_id);
|
|
74
|
-
case "Stop":
|
|
77
|
+
return joinContextResponses(subagentCacheContext(input.payload.session_id), prdSubagentContext(input.payload, input.cwd, input.id ?? "claude-code"));
|
|
78
|
+
case "Stop": {
|
|
75
79
|
if (input.scope === "lessons") return dispatchLessons("Stop", input.payload, input.cwd, input.now, input.id ?? "claude-code");
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
if (input.scope !== "core") return null;
|
|
81
|
+
const prdBlock = prdStopGate(input.payload, input.cwd, input.id ?? "claude-code", trackFile(sanitizeSessionId(input.payload.session_id) ?? "unknown", defaultStateDir(input.cwd)), input.now);
|
|
82
|
+
return prdBlock || stopCore(input.payload, input.cwd, input.now);
|
|
83
|
+
}
|
|
84
|
+
case "SubagentStop": {
|
|
78
85
|
// G0 counterpart of the SubagentStart branch above — the SAME
|
|
79
86
|
// monotone max-write, never a decrement (see confirm-subagent.ts).
|
|
80
87
|
markSubagentSeen(input.payload.session_id, input.now);
|
|
@@ -84,7 +91,13 @@ export function dispatchLifecycle(input: LifecycleInput): string | null {
|
|
|
84
91
|
// explore evidence even when sidechain PostToolUse hooks never fired
|
|
85
92
|
// (#43612/#27655/#34692). SubagentStop is main-session-dispatched (reliable).
|
|
86
93
|
harvestSubagentTrack(input.payload, input.cwd, input.now);
|
|
87
|
-
|
|
94
|
+
// null = PRD had nothing to say (off, unnamed agent, or genuinely done)
|
|
95
|
+
// -> normal trackAgentMemory handling; a non-null string (block, or ""
|
|
96
|
+
// on an already-blocked replay) must be returned AS-IS, never layered
|
|
97
|
+
// under a stale "agent completed" message.
|
|
98
|
+
const prdBlock = prdSubagentStopGate(input.payload, input.cwd, input.id ?? "claude-code", trackFile(sanitizeSessionId(input.payload.session_id) ?? "unknown", defaultStateDir(input.cwd)), input.now);
|
|
99
|
+
return prdBlock !== null ? prdBlock : trackAgentMemory(input.payload, undefined, input.now);
|
|
100
|
+
}
|
|
88
101
|
case "TeammateIdle":
|
|
89
102
|
return teammateIdleContext(input.payload, input.cwd, undefined, input.now);
|
|
90
103
|
case "PostToolUseFailure":
|
package/src/runtime/normalize.ts
CHANGED
|
@@ -33,6 +33,8 @@ export interface NormalizedEvent {
|
|
|
33
33
|
permissionMode?: string;
|
|
34
34
|
/** Codex logical tool-use identity, shared by sibling hook callbacks. */
|
|
35
35
|
toolUseId?: string;
|
|
36
|
+
/** Sub-agent identifier, if the tool-use came from one (Claude/Codex only — Cursor/Kimi never send this field, confirmed live). */
|
|
37
|
+
agentId?: string;
|
|
36
38
|
/** Harness-reported working directory used to scope Codex authorization. */
|
|
37
39
|
cwd?: string;
|
|
38
40
|
/** Validated Cursor multi-root workspace paths in wire order. */
|
|
@@ -71,6 +73,7 @@ export function normalizeEvent(id: string, payload: Record<string, unknown>): No
|
|
|
71
73
|
...extractCursorEvent(payload),
|
|
72
74
|
sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "",
|
|
73
75
|
agentType: str(payload.agent_type),
|
|
76
|
+
agentId: str(payload.agent_id),
|
|
74
77
|
permissionMode: str(payload.permission_mode),
|
|
75
78
|
};
|
|
76
79
|
}
|
|
@@ -83,6 +86,7 @@ export function normalizeEvent(id: string, payload: Record<string, unknown>): No
|
|
|
83
86
|
input,
|
|
84
87
|
sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "",
|
|
85
88
|
agentType: str(payload.agent_type) ?? str(input.subagent_type),
|
|
89
|
+
agentId: str(payload.agent_id),
|
|
86
90
|
permissionMode: str(payload.permission_mode),
|
|
87
91
|
toolUseId: str(payload.tool_use_id),
|
|
88
92
|
cwd: str(payload.cwd),
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Barrel for `src/runtime/prd/**` — the runtime wiring surface (design doc §2). */
|
|
2
|
+
export { prdPreGate } from "./prd-pre-gate";
|
|
3
|
+
export { prdPostCheck } from "./prd-post-check";
|
|
4
|
+
export { prdSubagentContext } from "./prd-subagent-context";
|
|
5
|
+
export { prdSubagentStopGate } from "./prd-subagent-stop";
|
|
6
|
+
export { prdStopGate } from "./prd-stop-gate";
|
|
7
|
+
export { resolvePrdIdentity } from "./prd-identity";
|
|
8
|
+
export { prdCandidateFiles } from "./prd-candidate-files";
|