@fusengine/harness 0.1.91 → 0.1.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/adapters/claude/index.mjs +1 -1
- package/dist/adapters/codex/index.mjs +1 -1
- package/dist/adapters/cursor/index.mjs +1 -1
- package/dist/adapters/hermes/index.mjs +1 -1
- package/dist/adapters/kimi/index.mjs +1 -1
- package/dist/apex-target-Xc2M32Pl.mjs +48 -0
- package/dist/apex-task-store-Cb65rE5i.mjs +467 -0
- package/dist/{claude-Ckv2_TgP.mjs → claude-D62hkUfS.mjs} +2 -84
- package/dist/cli/bin.mjs +7 -5
- package/dist/cli/index.d.mts +69 -1
- package/dist/cli/index.mjs +2 -2
- package/dist/config/index.mjs +2 -1
- package/dist/{dotenv-BLBkBTww.mjs → dotenv-C1LkcfW-.mjs} +1 -26
- package/dist/{handle-C43gA-Pr.mjs → handle-B-g9KL_9.mjs} +4837 -3466
- package/dist/{hermes-B9-p_3IF.mjs → hermes-ByopGx6C.mjs} +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +3 -2
- package/dist/{kimi-G2wcSh5-.mjs → kimi-C-Oia9q-.mjs} +1 -1
- package/dist/{normalize-BjG6unTj.mjs → normalize-Dy8g9Ybl.mjs} +83 -3
- package/dist/policy/index.mjs +1 -1
- package/dist/prd-PvK7PKWS.mjs +494 -0
- package/dist/runtime/index.d.mts +13 -1
- package/dist/runtime/index.mjs +1 -1
- package/dist/runtime-io-DuumUeE6.mjs +84 -0
- package/dist/{session-state-D5gLr66m.d.mts → session-state-COg7Ej_2.d.mts} +32 -1
- package/dist/{skill-path-DVML3zfp.mjs → skill-path-Cz8WFaGu.mjs} +1 -1
- package/dist/{store-5-ZPKb0u.mjs → store-BVY6gIYM.mjs} +75 -3
- package/dist/tracking/index.d.mts +2 -2
- package/dist/tracking/index.mjs +2 -2
- package/dist/ttl-Dgwg_QAv.mjs +26 -0
- package/dist/{validate-KjZ1X9tH.mjs → validate-Dcjl0LUS.mjs} +4 -49
- package/package.json +1 -1
- package/src/adapters/cursor/context-budget.ts +144 -0
- package/src/adapters/cursor/context-limit.ts +115 -0
- package/src/adapters/cursor/context.ts +21 -2
- package/src/adapters/cursor/interfaces/context-budget.ts +25 -0
- package/src/adapters/cursor/native-response.ts +10 -129
- package/src/adapters/cursor/native-schemas.ts +161 -0
- package/src/adapters/cursor/normalize.ts +65 -0
- package/src/adapters/cursor/plugin-root.ts +103 -0
- package/src/adapters/cursor/respond.ts +94 -47
- package/src/cli/bin.ts +3 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/prd/compact.ts +53 -0
- package/src/cli/prd/format.ts +23 -0
- package/src/cli/prd/index.ts +23 -0
- package/src/cli/prd/resolve.ts +88 -0
- package/src/cli/prd/shared.ts +80 -0
- package/src/cli/prd/status.ts +87 -0
- package/src/cli/prd/validate.ts +90 -0
- package/src/policy/prd/index.ts +36 -0
- package/src/policy/prd/interfaces/types.ts +103 -0
- package/src/policy/prd/prd-compact.ts +28 -0
- package/src/policy/prd/prd-context.ts +131 -0
- package/src/policy/prd/prd-crosscheck.ts +76 -0
- package/src/policy/prd/prd-enabled.ts +39 -0
- package/src/policy/prd/prd-io.ts +89 -0
- package/src/policy/prd/prd-ownership.ts +99 -0
- package/src/policy/prd/prd-paths.ts +88 -0
- package/src/policy/prd/prd-schema.ts +151 -0
- package/src/runtime/handle-post.ts +2 -0
- package/src/runtime/handle-pre.ts +11 -0
- package/src/runtime/handle.ts +87 -6
- package/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts +7 -1
- package/src/runtime/lifecycle/dispatch.ts +18 -5
- package/src/runtime/lifecycle/failure-lesson.ts +6 -2
- package/src/runtime/lifecycle/rules-root.ts +18 -2
- package/src/runtime/normalize.ts +4 -0
- package/src/runtime/prd/index.ts +8 -0
- package/src/runtime/prd/prd-bash-targets.ts +199 -0
- package/src/runtime/prd/prd-candidate-files.ts +23 -0
- package/src/runtime/prd/prd-canon.ts +50 -0
- package/src/runtime/prd/prd-identity.ts +32 -0
- package/src/runtime/prd/prd-post-check.ts +73 -0
- package/src/runtime/prd/prd-pre-gate.ts +172 -0
- package/src/runtime/prd/prd-stop-gate.ts +101 -0
- package/src/runtime/prd/prd-subagent-context.ts +69 -0
- package/src/runtime/prd/prd-subagent-stop.ts +152 -0
- package/src/tracking/session-state.ts +46 -0
- package/src/tracking/track-diff.ts +3 -0
- package/src/tracking/track-journal.ts +10 -1
- package/dist/run-DkrzC0gb.mjs +0 -42
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Barrel for `src/policy/prd/**` — the public surface Lot B and Lot C import from. */
|
|
2
|
+
export type {
|
|
3
|
+
PrdAgentEntryCompacted, PrdAgentEntryExpanded, PrdAgentReportFile, PrdAgentSubEntry,
|
|
4
|
+
PrdCrossCheckViolation, PrdIdentity, PrdOwnershipVerdict, PrdPathKind, PrdRouter,
|
|
5
|
+
PrdRouterEntry, PrdRouterStatus, PrdSubagentSlice, PrdSubStatus, PrdSubTask, PrdTaskAgentEntry,
|
|
6
|
+
PrdTaskFile,
|
|
7
|
+
} from "./interfaces/types";
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
classifyPrdPath, isPrdScopedPath, prdAgentReportPath, prdDir, prdDocsPath, prdRouterPath,
|
|
11
|
+
prdTaskPath,
|
|
12
|
+
} from "./prd-paths";
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
filesOf, isCompacted, parseAgentReportFile, parseRouter, parseTaskFile, subTasksOf,
|
|
16
|
+
validateTaskFileInvariant, withRouterStatus, withSubTaskValidated,
|
|
17
|
+
} from "./prd-schema";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
candidateAgentNames, evaluateWriteOwnership, matchesAgentName, resolveOwnerBinding,
|
|
21
|
+
} from "./prd-ownership";
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
crossCheckRouter, crossCheckTask, hasAnyViolations, incompleteSubTasks,
|
|
25
|
+
} from "./prd-crosscheck";
|
|
26
|
+
|
|
27
|
+
export { canPromoteRouterEntry, compactAgentEntry, compactTaskFile } from "./prd-compact";
|
|
28
|
+
|
|
29
|
+
export { agentSlice, agentSlices, joinContextResponses, renderAgentSliceMarkdown } from "./prd-context";
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
readAgentReport, readAgentReportSync, readAllTaskFiles, readRouter, readRouterSync,
|
|
33
|
+
readTaskFile, readTaskFileSync, writeAgentReport, writeRouter, writeTaskFile,
|
|
34
|
+
} from "./prd-io";
|
|
35
|
+
|
|
36
|
+
export { isPrdEnabled, isPrdFlagSet, prdProjectRoot, routerExistsSync } from "./prd-enabled";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared shapes for the PRD (task/agent ownership coordination) module. Pure
|
|
3
|
+
* types only — no runtime code, no fs. Mirrors the JSON contract at
|
|
4
|
+
* `<root>/<homeSeg>/apex/prd.json` and `<root>/<homeSeg>/apex/prd/**`.
|
|
5
|
+
*
|
|
6
|
+
* FROZEN CONTRACT: this file is imported by both Lot B (runtime wiring) and
|
|
7
|
+
* Lot C (CLI). Do not rename or remove an export without flagging it to the
|
|
8
|
+
* other lots first.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Lifecycle status of a task in the router (`prd.json`). */
|
|
12
|
+
export type PrdRouterStatus = "assigned" | "in-progress" | "validated";
|
|
13
|
+
|
|
14
|
+
/** One router entry: which task-PRD file backs a task, and its status. */
|
|
15
|
+
export interface PrdRouterEntry {
|
|
16
|
+
prd: string;
|
|
17
|
+
status: PrdRouterStatus;
|
|
18
|
+
"validated-at"?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The router file itself (`prd.json`), keyed by task name. */
|
|
22
|
+
export type PrdRouter = Record<string, PrdRouterEntry>;
|
|
23
|
+
|
|
24
|
+
/** Lifecycle status of one sub-task inside a task-PRD's agent entry. */
|
|
25
|
+
export type PrdSubStatus = "assigned" | "validated";
|
|
26
|
+
|
|
27
|
+
/** One sub-task's status inside a task-PRD's (expanded) agent entry. */
|
|
28
|
+
export interface PrdSubTask {
|
|
29
|
+
status: PrdSubStatus;
|
|
30
|
+
"validated-at"?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Pre-compaction shape of one agent's slice of a task-PRD. */
|
|
34
|
+
export interface PrdAgentEntryExpanded {
|
|
35
|
+
files: string[];
|
|
36
|
+
"sub-tasks": Record<string, PrdSubTask>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Post-compaction shape of one agent's slice of a task-PRD (all sub-tasks validated). */
|
|
40
|
+
export interface PrdAgentEntryCompacted {
|
|
41
|
+
status: "validated";
|
|
42
|
+
files: string[];
|
|
43
|
+
"validated-at": string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One agent's slice of a task-PRD, expanded or compacted. */
|
|
47
|
+
export type PrdTaskAgentEntry = PrdAgentEntryExpanded | PrdAgentEntryCompacted;
|
|
48
|
+
|
|
49
|
+
/** A task-PRD file (`prd/<task>-prd.json`), keyed by agent name. */
|
|
50
|
+
export type PrdTaskFile = Record<string, PrdTaskAgentEntry>;
|
|
51
|
+
|
|
52
|
+
/** One sub-task's completion report inside an agent's own report file. */
|
|
53
|
+
export interface PrdAgentSubEntry {
|
|
54
|
+
status: "done";
|
|
55
|
+
modified: string[];
|
|
56
|
+
unchanged: string[];
|
|
57
|
+
"done-at"?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** An agent's own report file (`prd/agents/<agent>-prd.json`), keyed by task then sub-task. */
|
|
61
|
+
export type PrdAgentReportFile = Record<string, Record<string, PrdAgentSubEntry>>;
|
|
62
|
+
|
|
63
|
+
/** Resolved identity of the current tool-use, for the ownership check. */
|
|
64
|
+
export interface PrdIdentity {
|
|
65
|
+
agentId?: string;
|
|
66
|
+
agentType?: string;
|
|
67
|
+
/**
|
|
68
|
+
* true = structurally proven lead (Claude/Codex: no agent_id/agent_type on
|
|
69
|
+
* the event); false = structurally proven sub-agent; "unknown" = harness
|
|
70
|
+
* can't tell (Cursor/Kimi — see design doc Risks §1/§2).
|
|
71
|
+
*/
|
|
72
|
+
lead: boolean | "unknown";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** PreToolUse ownership verdict for one in-scope PRD write. */
|
|
76
|
+
export type PrdOwnershipVerdict =
|
|
77
|
+
| { allow: true; bind?: { agentId: string; name: string } }
|
|
78
|
+
| { allow: false; reason: string }
|
|
79
|
+
| { allow: "advisory" };
|
|
80
|
+
|
|
81
|
+
/** One cross-check mismatch between a task-PRD's assignment and an agent's own report. */
|
|
82
|
+
export interface PrdCrossCheckViolation {
|
|
83
|
+
task: string;
|
|
84
|
+
agent: string;
|
|
85
|
+
sub: string;
|
|
86
|
+
reason: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One sub-agent's rendered slice of a task-PRD, for the SubagentStart injection. */
|
|
90
|
+
export interface PrdSubagentSlice {
|
|
91
|
+
task: string;
|
|
92
|
+
agent: string;
|
|
93
|
+
subTasks: string[];
|
|
94
|
+
files: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Classification of an in-scope PRD path (`isPrdScopedPath`/`classifyPrdPath`). */
|
|
98
|
+
export type PrdPathKind =
|
|
99
|
+
| { kind: "router" }
|
|
100
|
+
| { kind: "task"; task: string }
|
|
101
|
+
| { kind: "agentReport"; agent: string }
|
|
102
|
+
| { kind: "docs"; task: string }
|
|
103
|
+
| { kind: "other" };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Collapses fully-validated task-PRD agent entries to their compact shape. Pure — no fs. */
|
|
2
|
+
import { isCompacted, subTasksOf } from "./prd-schema";
|
|
3
|
+
import type { PrdTaskAgentEntry, PrdTaskFile } from "./interfaces/types";
|
|
4
|
+
|
|
5
|
+
/** Collapses one agent entry to the compacted shape ONLY when every sub-task is `validated`; else returns it unchanged. */
|
|
6
|
+
export function compactAgentEntry(e: PrdTaskAgentEntry, at: string): PrdTaskAgentEntry {
|
|
7
|
+
if (isCompacted(e)) return e;
|
|
8
|
+
const subTasks = Object.values(subTasksOf(e));
|
|
9
|
+
if (subTasks.length === 0 || !subTasks.every((s) => s.status === "validated")) return e;
|
|
10
|
+
return { status: "validated", files: e.files, "validated-at": at };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Compacts every eligible agent entry in a task-PRD; reports which agent names got collapsed. */
|
|
14
|
+
export function compactTaskFile(taskFile: PrdTaskFile, at: string): { file: PrdTaskFile; compacted: string[] } {
|
|
15
|
+
const compacted: string[] = [];
|
|
16
|
+
const file: PrdTaskFile = {};
|
|
17
|
+
for (const [agent, entry] of Object.entries(taskFile)) {
|
|
18
|
+
const next = compactAgentEntry(entry, at);
|
|
19
|
+
if (next !== entry) compacted.push(agent);
|
|
20
|
+
file[agent] = next;
|
|
21
|
+
}
|
|
22
|
+
return { file, compacted };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when every agent in the task-PRD is already compacted+validated. */
|
|
26
|
+
export function canPromoteRouterEntry(taskFile: PrdTaskFile): boolean {
|
|
27
|
+
return Object.values(taskFile).every((entry) => isCompacted(entry));
|
|
28
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SubagentStart "slice" text builder: renders only the sub-agent's own
|
|
3
|
+
* PRD assignment, never the whole task. Pure — no fs.
|
|
4
|
+
*/
|
|
5
|
+
import { candidateAgentNames } from "./prd-ownership";
|
|
6
|
+
import { filesOf, subTasksOf } from "./prd-schema";
|
|
7
|
+
import type { PrdSubagentSlice, PrdTaskFile } from "./interfaces/types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Builds the slice of `taskFile` owned by each candidate name matching
|
|
11
|
+
* `agentType` — one entry per candidate. Empty when no candidate matches;
|
|
12
|
+
* 2+ entries when the match is ambiguous (shared base type, e.g.
|
|
13
|
+
* `backend-expert`/`backend-expert-2`) so the caller can surface every
|
|
14
|
+
* possibility instead of silently dropping the assignment.
|
|
15
|
+
*/
|
|
16
|
+
export function agentSlices(taskFile: PrdTaskFile, agentType: string, task: string): PrdSubagentSlice[] {
|
|
17
|
+
const slices: PrdSubagentSlice[] = [];
|
|
18
|
+
for (const agent of candidateAgentNames(agentType, taskFile)) {
|
|
19
|
+
const entry = taskFile[agent];
|
|
20
|
+
if (entry) slices.push({ task, agent, subTasks: Object.keys(subTasksOf(entry)), files: filesOf(entry) });
|
|
21
|
+
}
|
|
22
|
+
return slices;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Builds the one slice of `taskFile` owned by `agentType`, or `null` when no
|
|
27
|
+
* candidate matches, or the match is ambiguous (2+ candidates — see
|
|
28
|
+
* {@link agentSlices} to get all of them instead).
|
|
29
|
+
*/
|
|
30
|
+
export function agentSlice(taskFile: PrdTaskFile, agentType: string, task: string): PrdSubagentSlice | null {
|
|
31
|
+
const slices = agentSlices(taskFile, agentType, task);
|
|
32
|
+
return slices.length === 1 ? (slices[0] ?? null) : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderSliceBlock(s: PrdSubagentSlice): string {
|
|
36
|
+
return [
|
|
37
|
+
`## PRD assignment — task ${s.task}`,
|
|
38
|
+
`Your files: ${s.files.join(", ")}`,
|
|
39
|
+
`Your sub-tasks: ${s.subTasks.join(", ")}`,
|
|
40
|
+
`Report to prd/agents/${s.agent}-prd.json when done.`,
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Renders one readable markdown block per slice: title, files, sub-tasks,
|
|
46
|
+
* report path. When 2+ slices share the same `task` (an ambiguous
|
|
47
|
+
* `agentSlices` result), prefixes that group with an explicit
|
|
48
|
+
* disambiguation header instead of silently rendering as if unambiguous.
|
|
49
|
+
*/
|
|
50
|
+
export function renderAgentSliceMarkdown(slices: PrdSubagentSlice[]): string {
|
|
51
|
+
const byTask = new Map<string, PrdSubagentSlice[]>();
|
|
52
|
+
for (const s of slices) {
|
|
53
|
+
const group = byTask.get(s.task);
|
|
54
|
+
if (group) group.push(s); else byTask.set(s.task, [s]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const blocks: string[] = [];
|
|
58
|
+
for (const group of byTask.values()) {
|
|
59
|
+
if (group.length > 1) {
|
|
60
|
+
const names = group.map((s) => s.agent).join(", ");
|
|
61
|
+
blocks.push(
|
|
62
|
+
`Several assignments match your agent type. You are ONE of: ${names}. `
|
|
63
|
+
+ "The first report you write binds your name; write only that report.",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
for (const s of group) blocks.push(renderSliceBlock(s));
|
|
67
|
+
}
|
|
68
|
+
return blocks.join("\n\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One rendered context part's extracted text, plus which envelope it came from. Local implementation detail — never exported (not part of the frozen `interfaces/types.ts` contract). */
|
|
72
|
+
interface ExtractedContext {
|
|
73
|
+
text: string;
|
|
74
|
+
/** true = cline's native `{contextModification}` shape (see `respond()`'s `"cline"` branch in `runtime/respond.ts`); false = the Claude-family `hookSpecificOutput.additionalContext` envelope every other target (including gemini-cli, which omits `hookEventName` but keeps this same key path) uses. */
|
|
75
|
+
native: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** True for a non-null JSON object `JSON.parse` can produce (excludes arrays/primitives/null). */
|
|
79
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
80
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extracts the text + originating shape from one already-rendered context
|
|
85
|
+
* response (parse-then-narrow, no unchecked `as` cast on the parsed value —
|
|
86
|
+
* see {@link isRecord}), or `null` when the input fails to parse, or parses
|
|
87
|
+
* to neither known shape.
|
|
88
|
+
*/
|
|
89
|
+
function contextTextOf(response: string): ExtractedContext | null {
|
|
90
|
+
let parsed: unknown;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(response);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (!isRecord(parsed)) return null;
|
|
97
|
+
const hso = parsed.hookSpecificOutput;
|
|
98
|
+
const claudeText = isRecord(hso) && typeof hso.additionalContext === "string" ? hso.additionalContext : "";
|
|
99
|
+
if (claudeText) return { text: claudeText, native: false };
|
|
100
|
+
const clineText = typeof parsed.contextModification === "string" ? parsed.contextModification : "";
|
|
101
|
+
if (clineText) return { text: clineText, native: true };
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Joins two already-rendered `contextResponse(...)`-shaped JSON strings into
|
|
107
|
+
* one (same fold `dispatch-aipilot.ts`'s `combineContext` already does) —
|
|
108
|
+
* kept local to avoid a 3rd import edge into that file.
|
|
109
|
+
*
|
|
110
|
+
* Recognizes BOTH the Claude-family `hookSpecificOutput.additionalContext`
|
|
111
|
+
* envelope (claude-code/codex/kimi/cursor/hermes/gemini-cli) and cline's
|
|
112
|
+
* native `{contextModification}` shape (see `runtime/respond.ts`'s `"cline"`
|
|
113
|
+
* branch). A cline-shaped part used to parse to `""` here and get silently
|
|
114
|
+
* dropped by the old `.filter(Boolean)` — exactly how cline's PRD slice never
|
|
115
|
+
* reached the agent (0-byte `SubagentStart` injection) despite
|
|
116
|
+
* `prdSubagentContext` building it correctly. When any merged part is
|
|
117
|
+
* cline-native, the output is re-shaped as `{contextModification}` so
|
|
118
|
+
* cline's own hook consumer (which never reads `hookSpecificOutput`) can
|
|
119
|
+
* read it; every other target only ever produces Claude-family parts, so
|
|
120
|
+
* that branch never triggers for them and their output stays
|
|
121
|
+
* byte-identical.
|
|
122
|
+
*/
|
|
123
|
+
export function joinContextResponses(...parts: string[]): string {
|
|
124
|
+
const extracted = parts.map(contextTextOf).filter((e): e is ExtractedContext => e !== null);
|
|
125
|
+
if (extracted.length === 0) return "";
|
|
126
|
+
const joined = extracted.map((e) => e.text).join("\n\n");
|
|
127
|
+
if (extracted.some((e) => e.native)) return JSON.stringify({ contextModification: joined });
|
|
128
|
+
return JSON.stringify({
|
|
129
|
+
hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext: joined },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-checks a task-PRD's declared status against the agents' own reports
|
|
3
|
+
* (and, for the router, against the task-PRD's own internal consistency).
|
|
4
|
+
* Pure — no fs.
|
|
5
|
+
*/
|
|
6
|
+
import { isCompacted, subTasksOf } from "./prd-schema";
|
|
7
|
+
import type {
|
|
8
|
+
PrdAgentReportFile, PrdCrossCheckViolation, PrdRouter, PrdTaskFile,
|
|
9
|
+
} from "./interfaces/types";
|
|
10
|
+
|
|
11
|
+
/** Mismatches between a task-PRD's `validated` sub-tasks and the agents' own reports. */
|
|
12
|
+
export function crossCheckTask(
|
|
13
|
+
taskFile: PrdTaskFile,
|
|
14
|
+
reports: Record<string, PrdAgentReportFile>,
|
|
15
|
+
task: string,
|
|
16
|
+
): PrdCrossCheckViolation[] {
|
|
17
|
+
const violations: PrdCrossCheckViolation[] = [];
|
|
18
|
+
for (const [agent, entry] of Object.entries(taskFile)) {
|
|
19
|
+
if (isCompacted(entry)) continue;
|
|
20
|
+
const report = reports[agent];
|
|
21
|
+
for (const [sub, subTask] of Object.entries(subTasksOf(entry))) {
|
|
22
|
+
if (subTask.status !== "validated") continue;
|
|
23
|
+
if (report?.[task]?.[sub]?.status !== "done") {
|
|
24
|
+
violations.push({ task, agent, sub, reason: "validated without a matching done report" });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return violations;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function taskFullyValidated(taskFile: PrdTaskFile): boolean {
|
|
32
|
+
return Object.values(taskFile).every((entry) => {
|
|
33
|
+
if (isCompacted(entry)) return true;
|
|
34
|
+
return Object.values(subTasksOf(entry)).every((sub) => sub.status === "validated");
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Router-level consistency check: a router entry marked `validated` whose
|
|
40
|
+
* task-PRD is missing/unparseable, or not actually fully validated, is a
|
|
41
|
+
* violation. Non-`validated` router entries are never checked.
|
|
42
|
+
*/
|
|
43
|
+
export function crossCheckRouter(
|
|
44
|
+
router: PrdRouter,
|
|
45
|
+
taskFiles: Record<string, PrdTaskFile | null>,
|
|
46
|
+
): PrdCrossCheckViolation[] {
|
|
47
|
+
const violations: PrdCrossCheckViolation[] = [];
|
|
48
|
+
for (const [task, entry] of Object.entries(router)) {
|
|
49
|
+
if (entry.status !== "validated") continue;
|
|
50
|
+
const taskFile = taskFiles[task];
|
|
51
|
+
if (!taskFile) {
|
|
52
|
+
violations.push({ task, agent: "*", sub: "*", reason: "router marked validated but task-PRD is missing or unparseable" });
|
|
53
|
+
} else if (!taskFullyValidated(taskFile)) {
|
|
54
|
+
violations.push({ task, agent: "*", sub: "*", reason: "router marked validated but task-PRD is not fully validated" });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return violations;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Sub-tasks assigned to `agent` in `taskFile` for `task` not `done` in its own report. */
|
|
61
|
+
export function incompleteSubTasks(
|
|
62
|
+
taskFile: PrdTaskFile,
|
|
63
|
+
agent: string,
|
|
64
|
+
task: string,
|
|
65
|
+
report: PrdAgentReportFile | null,
|
|
66
|
+
): string[] {
|
|
67
|
+
const entry = taskFile[agent];
|
|
68
|
+
if (!entry || isCompacted(entry)) return [];
|
|
69
|
+
const doneSubs = report?.[task] ?? {};
|
|
70
|
+
return Object.keys(subTasksOf(entry)).filter((sub) => doneSubs[sub]?.status !== "done");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when the router has any `validated`-vs-task-PRD mismatch. */
|
|
74
|
+
export function hasAnyViolations(router: PrdRouter, taskFiles: Record<string, PrdTaskFile | null>): boolean {
|
|
75
|
+
return crossCheckRouter(router, taskFiles).length > 0;
|
|
76
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single activation check for the whole PRD module. Everything
|
|
3
|
+
* downstream short-circuits on `false` here — this is the only place the
|
|
4
|
+
* module's "totally inert when off" contract lives. The other file in this
|
|
5
|
+
* module that touches disk (besides `prd-io.ts`).
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { isAbsolute } from "node:path";
|
|
9
|
+
import { harnessHomeSegment } from "../apex-target";
|
|
10
|
+
import { prdRouterPath } from "./prd-paths";
|
|
11
|
+
|
|
12
|
+
/** True only when `FUSE_PRD` is the exact string `"1"` — no truthy-string leniency. */
|
|
13
|
+
export function isPrdFlagSet(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
14
|
+
return env.FUSE_PRD === "1";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `CLAUDE_PROJECT_DIR` then `CURSOR_PROJECT_DIR` (first absolute value wins), else `cwd`. */
|
|
18
|
+
export function prdProjectRoot(cwd: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
19
|
+
for (const candidate of [env.CLAUDE_PROJECT_DIR, env.CURSOR_PROJECT_DIR]) {
|
|
20
|
+
if (candidate !== undefined && isAbsolute(candidate)) return candidate;
|
|
21
|
+
}
|
|
22
|
+
return cwd;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when the router file exists on disk. */
|
|
26
|
+
export function routerExistsSync(root: string, homeSeg: string): boolean {
|
|
27
|
+
return existsSync(prdRouterPath(root, homeSeg));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Activation = `FUSE_PRD=1` AND the router is present. Short-circuits on the
|
|
32
|
+
* env check first, so a disabled module never touches disk.
|
|
33
|
+
*/
|
|
34
|
+
export function isPrdEnabled(cwd: string, id: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
35
|
+
if (!isPrdFlagSet(env)) return false;
|
|
36
|
+
const root = prdProjectRoot(cwd, env);
|
|
37
|
+
const homeSeg = harnessHomeSegment(id);
|
|
38
|
+
return routerExistsSync(root, homeSeg);
|
|
39
|
+
}
|
|
@@ -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
|
+
}
|