@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,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";
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module prd-bash-targets
|
|
3
|
+
* Local, PRD-module-only extension of Bash write-target detection BEYOND
|
|
4
|
+
* shell output redirects (`>`/`>>`, already covered by `shellOutputRedirects`
|
|
5
|
+
* in `bash-write-redirects.ts`). Static, best-effort heuristic scanner (never
|
|
6
|
+
* executes the command) for the write-target argument of common non-redirect
|
|
7
|
+
* write verbs: `cp`, `mv`, `install` (last positional = destination), `tee`
|
|
8
|
+
* (every positional = a destination), `sed -i` / `perl -i` (in-place edit —
|
|
9
|
+
* last positional = the edited file), and `dd of=` (the `of=` operand).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately NOT merged into `bash-write-redirects.ts` or
|
|
12
|
+
* `protectedPathGuard` (`protected-path.ts`) — both are shared, harness-wide
|
|
13
|
+
* guards; changing them risks a global regression. This is a narrow,
|
|
14
|
+
* PRD-scoped sibling used ONLY by `prdPreGate`'s Bash branch, feeding its
|
|
15
|
+
* output through the SAME `isPrdScopedPath` check the redirect targets
|
|
16
|
+
* already go through — so a target outside the PRD tree is never affected.
|
|
17
|
+
*
|
|
18
|
+
* Known limitation: `--` (POSIX end-of-options, GNU coreutils/glibc
|
|
19
|
+
* `getopt_long`) is honored — a positional arg starting with `-` AFTER `--`
|
|
20
|
+
* is never mistaken for an option — but `getopt_long`'s default PERMUTE mode
|
|
21
|
+
* lets a value-taking option (`install -m 644 file dest`, `cp -t DIR a b`)
|
|
22
|
+
* land anywhere in argv; this scanner doesn't track which options consume a
|
|
23
|
+
* following value, so a bare value like `644` could in principle be
|
|
24
|
+
* mistaken for the destination if it were the LAST token. Best-effort only
|
|
25
|
+
* (mirrors the existing `protectedPathGuard#extractWriteTargets`
|
|
26
|
+
* precedent): can under/mis-detect an unusual invocation, never cause a
|
|
27
|
+
* false deny on an out-of-scope path (the caller still scope-checks every
|
|
28
|
+
* returned target). Not applied to `dd`, whose `if=`/`of=` operands are
|
|
29
|
+
* never getopt-parsed (coreutils docs: "the only options are
|
|
30
|
+
* --help/--version").
|
|
31
|
+
*
|
|
32
|
+
* 2nd limitation (unfixed, flagged for owner): bundled `-i` (`perl -pi -e`,
|
|
33
|
+
* `sed -ni`) is missed — only a standalone `-i`/`-i<suffix>` token is seen.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Chain separators this splits a command on (quote/paren-depth aware — never splits inside quotes or `$(...)`/backticks). */
|
|
37
|
+
const CHAIN_CHARS = new Set([";", "&", "|", "\n"]);
|
|
38
|
+
|
|
39
|
+
/** Balanced-paren scan for a `$(...)` body, quote-aware. @returns Index of the matching `)`, or `input.length` if unterminated. */
|
|
40
|
+
function closingParen(input: string, start: number): number {
|
|
41
|
+
let depth = 1;
|
|
42
|
+
let quote: "'" | '"' | null = null;
|
|
43
|
+
for (let i = start; i < input.length; i++) {
|
|
44
|
+
const ch = input[i];
|
|
45
|
+
if (ch === "\\" && quote !== "'") { i++; continue; }
|
|
46
|
+
if (quote) { if (ch === quote) quote = null; continue; }
|
|
47
|
+
if (ch === "'" || ch === '"') { quote = ch; continue; }
|
|
48
|
+
if (ch === "(") depth++;
|
|
49
|
+
else if (ch === ")" && --depth === 0) return i;
|
|
50
|
+
}
|
|
51
|
+
return input.length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Splits `input[start, end)` into "simple command" strings on unquoted chain
|
|
56
|
+
* separators, recursing into `$(...)`/backtick substitutions so a write verb
|
|
57
|
+
* hidden inside a subshell is still found. Bounded: every recursive call
|
|
58
|
+
* strictly narrows `[start, end)`.
|
|
59
|
+
* @param out - Accumulator for discovered simple-command strings.
|
|
60
|
+
*/
|
|
61
|
+
function scanCommands(input: string, start: number, end: number, out: string[]): void {
|
|
62
|
+
let cmdStart = start;
|
|
63
|
+
let quote: "'" | '"' | null = null;
|
|
64
|
+
const flush = (to: number): void => {
|
|
65
|
+
const seg = input.slice(cmdStart, to).trim();
|
|
66
|
+
if (seg) out.push(seg);
|
|
67
|
+
};
|
|
68
|
+
for (let i = start; i < end; i++) {
|
|
69
|
+
const ch = input[i];
|
|
70
|
+
if (ch === "\\" && quote !== "'") { i++; continue; }
|
|
71
|
+
// Single quotes are fully opaque (bash: NO substitution inside `'...'`).
|
|
72
|
+
// Double quotes still perform command substitution (bash: `"$(...)"` and
|
|
73
|
+
// `` "`...`" `` both still execute) — only word-splitting/globbing is
|
|
74
|
+
// suppressed — so `$(`/backtick must still be checked while `quote==='"'`.
|
|
75
|
+
if (quote === "'") { if (ch === "'") quote = null; continue; }
|
|
76
|
+
if (ch === "$" && input[i + 1] === "(") {
|
|
77
|
+
const close = closingParen(input, i + 2);
|
|
78
|
+
scanCommands(input, i + 2, close, out);
|
|
79
|
+
i = close;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (ch === "`") {
|
|
83
|
+
const close = input.indexOf("`", i + 1);
|
|
84
|
+
const safeClose = close === -1 || close > end ? end : close;
|
|
85
|
+
scanCommands(input, i + 1, safeClose, out);
|
|
86
|
+
i = safeClose;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (quote === '"') { if (ch === '"') quote = null; continue; }
|
|
90
|
+
if (ch === "'" || ch === '"') { quote = ch; continue; }
|
|
91
|
+
if (CHAIN_CHARS.has(ch ?? "")) {
|
|
92
|
+
flush(i);
|
|
93
|
+
cmdStart = i + 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
flush(end);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Tokenizes one simple-command string into unquoted words (quotes unwrapped,
|
|
102
|
+
* backslash-escapes resolved outside single quotes) — same unquoting
|
|
103
|
+
* contract as `bash-write-redirects.ts`'s own `readTarget`.
|
|
104
|
+
*/
|
|
105
|
+
function tokenize(segment: string): string[] {
|
|
106
|
+
const tokens: string[] = [];
|
|
107
|
+
let cur = "";
|
|
108
|
+
let quote: "'" | '"' | null = null;
|
|
109
|
+
let started = false;
|
|
110
|
+
for (let i = 0; i < segment.length; i++) {
|
|
111
|
+
const ch = segment[i] ?? "";
|
|
112
|
+
if (ch === "\\" && quote !== "'") {
|
|
113
|
+
if (i + 1 < segment.length) { cur += segment[++i] ?? ""; started = true; }
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (quote) {
|
|
117
|
+
if (ch === quote) quote = null; else cur += ch;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (ch === "'" || ch === '"') { quote = ch; started = true; continue; }
|
|
121
|
+
if (/\s/.test(ch)) {
|
|
122
|
+
if (started) { tokens.push(cur); cur = ""; started = false; }
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
cur += ch;
|
|
126
|
+
started = true;
|
|
127
|
+
}
|
|
128
|
+
if (started) tokens.push(cur);
|
|
129
|
+
return tokens;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** True for a short/long option token (never a bare `-`, the stdin/stdout idiom). */
|
|
133
|
+
function isOption(t: string): boolean {
|
|
134
|
+
return t.startsWith("-") && t !== "-";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Positional (non-option) arguments, honoring `--` as POSIX end-of-options
|
|
139
|
+
* (see module doc): every token after a literal `--` is positional even if
|
|
140
|
+
* it starts with `-`.
|
|
141
|
+
*/
|
|
142
|
+
function positionalArgs(args: string[]): string[] {
|
|
143
|
+
const out: string[] = [];
|
|
144
|
+
let optionsEnded = false;
|
|
145
|
+
for (const t of args) {
|
|
146
|
+
if (!optionsEnded && t === "--") { optionsEnded = true; continue; }
|
|
147
|
+
if (!optionsEnded && isOption(t)) continue;
|
|
148
|
+
out.push(t);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Write-target argument(s) of one verb invocation, given its ARGUMENTS (verb
|
|
155
|
+
* token excluded). Empty when the verb isn't covered, or has no destination.
|
|
156
|
+
* @param verb - The verb basename (leading directory component stripped).
|
|
157
|
+
*/
|
|
158
|
+
function verbWriteTargets(verb: string, args: string[]): string[] {
|
|
159
|
+
if (verb === "dd") {
|
|
160
|
+
const of = args.find((t) => t.startsWith("of="));
|
|
161
|
+
return of ? [of.slice(3)] : [];
|
|
162
|
+
}
|
|
163
|
+
if (verb === "tee") return positionalArgs(args);
|
|
164
|
+
if (verb === "cp" || verb === "mv" || verb === "install") {
|
|
165
|
+
const p = positionalArgs(args);
|
|
166
|
+
return p.length > 0 ? [p[p.length - 1] ?? ""] : [];
|
|
167
|
+
}
|
|
168
|
+
if (verb === "sed" || verb === "perl") {
|
|
169
|
+
const inPlace = args.some((t) => t.startsWith("-i") || t === "--in-place" || t.startsWith("--in-place="));
|
|
170
|
+
if (!inPlace) return [];
|
|
171
|
+
const p = positionalArgs(args);
|
|
172
|
+
return p.length > 0 ? [p[p.length - 1] ?? ""] : [];
|
|
173
|
+
}
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Extracts candidate write-target paths from a Bash command string, for the
|
|
179
|
+
* non-redirect write verbs this module covers (`cp`, `mv`, `install`, `tee`,
|
|
180
|
+
* `sed -i`/`perl -i`, `dd of=`). Read-only usages (a verb that never writes,
|
|
181
|
+
* or a write verb whose only in-scope path is a SOURCE argument) never
|
|
182
|
+
* contribute a target — callers still resolve/scope-check every returned
|
|
183
|
+
* path themselves (this function does no fs access, no scoping decision).
|
|
184
|
+
* @param command - The raw Bash command string.
|
|
185
|
+
* @returns Candidate write-target paths (possibly empty/duplicated).
|
|
186
|
+
*/
|
|
187
|
+
export function extraBashWriteTargets(command: string): string[] {
|
|
188
|
+
const segments: string[] = [];
|
|
189
|
+
scanCommands(command, 0, command.length, segments);
|
|
190
|
+
const out: string[] = [];
|
|
191
|
+
for (const seg of segments) {
|
|
192
|
+
const tokens = tokenize(seg);
|
|
193
|
+
const verbToken = tokens[0];
|
|
194
|
+
if (!verbToken) continue;
|
|
195
|
+
const verb = verbToken.split("/").pop() ?? verbToken;
|
|
196
|
+
out.push(...verbWriteTargets(verb, tokens.slice(1)));
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|