@cassiomc1/forgeloop 0.1.0
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/.cursor/rules/project-loop.mdc +18 -0
- package/.forgeloop/.gitignore +2 -0
- package/.github/copilot-instructions.md +16 -0
- package/AGENTS.md +16 -0
- package/AGENT_COMPATIBILITY.md +147 -0
- package/CLAUDE.md +14 -0
- package/CONTRACT_COVERAGE.md +27 -0
- package/DELEGATION_PROTOCOL.md +91 -0
- package/ENG/accessibility-eng.md +155 -0
- package/ENG/clean-code-eng.md +223 -0
- package/ENG/design-code-eng.md +511 -0
- package/ENG/games-code-design-web-eng.md +751 -0
- package/ENG/perf-code-eng.md +441 -0
- package/ENG/premium-sites-studio-eng.md +320 -0
- package/ENG/sec-code-eng.md +706 -0
- package/ENG/test-code-eng.md +257 -0
- package/EXECUTION_STATE.md +107 -0
- package/GUIDE_ROUTER.md +274 -0
- package/LICENSE +21 -0
- package/LICENSE-DOCS.md +13 -0
- package/LOOP_ENGINEERING.md +551 -0
- package/LOOP_SYSTEM_DESIGN.md +394 -0
- package/ORCHESTRATOR_INTEGRATION.md +106 -0
- package/PROJECT_PROFILE.md +124 -0
- package/QUALITY_SCORECARD.md +54 -0
- package/README.md +492 -0
- package/TERMINOLOGY.md +21 -0
- package/THIRD_PARTY_NOTICES.md +129 -0
- package/THREAT_MODEL.md +35 -0
- package/package.json +51 -0
- package/schemas/delegated-result.schema.json +33 -0
- package/schemas/evidence.schema.json +15 -0
- package/schemas/execution-receipt.schema.json +46 -0
- package/schemas/routing-input.schema.json +17 -0
- package/schemas/routing-result.schema.json +17 -0
- package/schemas/task-brief.schema.json +24 -0
- package/schemas/work-state.schema.json +46 -0
- package/src/cli.js +341 -0
- package/src/commands/clear-state.js +11 -0
- package/src/commands/doctor.js +165 -0
- package/src/commands/init.js +42 -0
- package/src/commands/inspect.js +17 -0
- package/src/commands/route.js +32 -0
- package/src/commands/status.js +29 -0
- package/src/commands/update.js +109 -0
- package/src/commands/validate-protocol.js +133 -0
- package/src/commands/validate-receipt.js +19 -0
- package/src/commands/validate-state.js +30 -0
- package/src/core/agent-support.js +89 -0
- package/src/core/conformance.js +133 -0
- package/src/core/delegation.js +283 -0
- package/src/core/evidence.js +56 -0
- package/src/core/filesystem.js +122 -0
- package/src/core/inspect.js +115 -0
- package/src/core/json-safety.js +54 -0
- package/src/core/manifest.js +75 -0
- package/src/core/protocol.js +81 -0
- package/src/core/receipt.js +129 -0
- package/src/core/repository.js +19 -0
- package/src/core/router.js +296 -0
- package/src/core/schema-validation.js +179 -0
- package/src/core/templates.js +56 -0
- package/src/core/work-state.js +471 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { PROTOCOL_VERSION } from "./protocol.js";
|
|
2
|
+
import { createEvidence } from "./evidence.js";
|
|
3
|
+
|
|
4
|
+
function error(code, message, artifacts = []) {
|
|
5
|
+
return { code, message, artifacts };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function addVersionErrors(artifact, label, errors) {
|
|
9
|
+
if (!artifact) return;
|
|
10
|
+
if (artifact.schemaVersion !== 1 || artifact.protocolVersion !== PROTOCOL_VERSION) {
|
|
11
|
+
errors.push(error(
|
|
12
|
+
"UNSUPPORTED_PROTOCOL_VERSION",
|
|
13
|
+
`${label} must use schemaVersion 1 and protocolVersion ${PROTOCOL_VERSION}`,
|
|
14
|
+
[label],
|
|
15
|
+
));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sortErrors(errors) {
|
|
20
|
+
return errors.sort((left, right) => left.code.localeCompare(right.code)
|
|
21
|
+
|| left.artifacts.join("\0").localeCompare(right.artifacts.join("\0"))
|
|
22
|
+
|| left.message.localeCompare(right.message));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function validateTaskArtifactSet({
|
|
26
|
+
route = null,
|
|
27
|
+
state = null,
|
|
28
|
+
stateClassification = null,
|
|
29
|
+
receipt = null,
|
|
30
|
+
taskBriefs = [],
|
|
31
|
+
delegatedResults = [],
|
|
32
|
+
} = {}) {
|
|
33
|
+
const errors = [];
|
|
34
|
+
const incomplete = [];
|
|
35
|
+
|
|
36
|
+
addVersionErrors(route, "route", errors);
|
|
37
|
+
addVersionErrors(state, "state", errors);
|
|
38
|
+
addVersionErrors(receipt, "receipt", errors);
|
|
39
|
+
for (const brief of taskBriefs) addVersionErrors(brief, `taskBrief:${brief?.taskId ?? "unknown"}`, errors);
|
|
40
|
+
for (const result of delegatedResults) addVersionErrors(result, `delegatedResult:${result?.taskId ?? "unknown"}`, errors);
|
|
41
|
+
|
|
42
|
+
if (route && state && route.protocolVersion !== state.protocolVersion) {
|
|
43
|
+
errors.push(error("ROUTE_STATE_PROTOCOL_MISMATCH", "routing-result and work-state protocol versions differ", ["route", "state"]));
|
|
44
|
+
}
|
|
45
|
+
if (state && receipt && state.contractFingerprint !== receipt.contractFingerprint) {
|
|
46
|
+
errors.push(error("STATE_RECEIPT_CONTRACT_MISMATCH", "work-state and execution-receipt contract fingerprints differ", ["state", "receipt"]));
|
|
47
|
+
}
|
|
48
|
+
if (state && receipt && state.taskId !== receipt.taskId) {
|
|
49
|
+
errors.push(error("STATE_RECEIPT_TASK_MISMATCH", "work-state and execution-receipt task IDs differ", ["state", "receipt"]));
|
|
50
|
+
}
|
|
51
|
+
if (route && state && JSON.stringify(route.guides) !== JSON.stringify(state.selectedGuides)) {
|
|
52
|
+
errors.push(error("ROUTE_STATE_GUIDES_MISMATCH", "work-state.selectedGuides must equal routing-result.guides", ["route", "state"]));
|
|
53
|
+
}
|
|
54
|
+
if (state && receipt && JSON.stringify(state.selectedGuides) !== JSON.stringify(receipt.selectedGuides)) {
|
|
55
|
+
errors.push(error("STATE_RECEIPT_GUIDES_MISMATCH", "execution-receipt.selectedGuides must equal work-state.selectedGuides", ["state", "receipt"]));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const briefIds = new Set();
|
|
59
|
+
for (const brief of taskBriefs) {
|
|
60
|
+
if (!brief?.taskId) continue;
|
|
61
|
+
if (briefIds.has(brief.taskId)) {
|
|
62
|
+
errors.push(error("DUPLICATE_TASK_ID", `task brief ID is duplicated: ${brief.taskId}`, [`taskBrief:${brief.taskId}`]));
|
|
63
|
+
}
|
|
64
|
+
briefIds.add(brief.taskId);
|
|
65
|
+
if (state && brief.parentTaskId !== state.taskId) {
|
|
66
|
+
errors.push(error("TASK_PARENT_MISMATCH", `task brief ${brief.taskId} does not belong to ${state.taskId}`, [`taskBrief:${brief.taskId}`, "state"]));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const delegatedIds = new Set();
|
|
71
|
+
for (const result of delegatedResults) {
|
|
72
|
+
if (!result?.taskId) continue;
|
|
73
|
+
if (delegatedIds.has(result.taskId)) {
|
|
74
|
+
errors.push(error("DUPLICATE_DELEGATED_RESULT", `delegated result is duplicated: ${result.taskId}`, [`delegatedResult:${result.taskId}`]));
|
|
75
|
+
}
|
|
76
|
+
delegatedIds.add(result.taskId);
|
|
77
|
+
if (!briefIds.has(result.taskId)) {
|
|
78
|
+
errors.push(error("UNKNOWN_DELEGATED_TASK", `delegated result has no corresponding task brief: ${result.taskId}`, [`delegatedResult:${result.taskId}`]));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (taskBriefs.length > 0) {
|
|
83
|
+
for (const taskId of [...briefIds].sort()) {
|
|
84
|
+
if (!delegatedIds.has(taskId)) incomplete.push(`missing delegated result: ${taskId}`);
|
|
85
|
+
}
|
|
86
|
+
} else if (delegatedResults.length === 0) {
|
|
87
|
+
incomplete.push("task briefs and delegated results were not supplied");
|
|
88
|
+
}
|
|
89
|
+
if (!route || !state || !receipt) incomplete.push("route, state, and receipt are all required for a complete artifact set");
|
|
90
|
+
|
|
91
|
+
const sortedErrors = sortErrors(errors);
|
|
92
|
+
let status = "VALID";
|
|
93
|
+
if (sortedErrors.some((item) => item.code === "UNSUPPORTED_PROTOCOL_VERSION")) {
|
|
94
|
+
status = "INVALID";
|
|
95
|
+
} else if (sortedErrors.length > 0) {
|
|
96
|
+
status = "INCONSISTENT";
|
|
97
|
+
} else if (stateClassification?.status === "REVALIDATION_REQUIRED") {
|
|
98
|
+
status = "STALE";
|
|
99
|
+
} else if (incomplete.length > 0) {
|
|
100
|
+
status = "INCOMPLETE";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const stale = status === "STALE"
|
|
104
|
+
? {
|
|
105
|
+
reasons: [...(stateClassification?.reasons ?? [])],
|
|
106
|
+
warnings: [...(stateClassification?.warnings ?? [])],
|
|
107
|
+
repositoryComparison: stateClassification?.repositoryComparison ?? "NOT_VERIFIED",
|
|
108
|
+
contractComparison: stateClassification?.contractComparison ?? "NOT_VERIFIED",
|
|
109
|
+
artifactComparison: stateClassification?.artifactComparison ?? "NOT_APPLICABLE",
|
|
110
|
+
}
|
|
111
|
+
: null;
|
|
112
|
+
|
|
113
|
+
const evidenceKind = status === "VALID"
|
|
114
|
+
? "OBSERVED"
|
|
115
|
+
: status === "INCOMPLETE"
|
|
116
|
+
? "NOT_VERIFIED"
|
|
117
|
+
: status === "STALE"
|
|
118
|
+
? "INFERRED"
|
|
119
|
+
: status === "INVALID"
|
|
120
|
+
? "BLOCKED"
|
|
121
|
+
: "OBSERVED";
|
|
122
|
+
return {
|
|
123
|
+
status,
|
|
124
|
+
errors: sortedErrors,
|
|
125
|
+
incomplete: [...new Set(incomplete)].sort(),
|
|
126
|
+
stale,
|
|
127
|
+
evidence: [createEvidence({
|
|
128
|
+
kind: evidenceKind,
|
|
129
|
+
source: "ForgeLoop protocol conformance",
|
|
130
|
+
result: status,
|
|
131
|
+
})],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { GUIDE_IDS, PROTOCOL_VERSION } from "./protocol.js";
|
|
4
|
+
import { assertSchema, readSchema } from "./schema-validation.js";
|
|
5
|
+
import { assertSecretFree } from "./receipt.js";
|
|
6
|
+
import { assertEvidenceList } from "./evidence.js";
|
|
7
|
+
|
|
8
|
+
const DELEGATION_SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
function normalizePath(value, label) {
|
|
11
|
+
if (typeof value !== "string" || !value) throw new Error(`${label} must contain non-empty paths`);
|
|
12
|
+
const portable = value.replaceAll("\\", "/");
|
|
13
|
+
if (portable.startsWith("/") || /^[A-Za-z]:\//.test(portable)) {
|
|
14
|
+
throw new Error(`${label} must remain relative: ${value}`);
|
|
15
|
+
}
|
|
16
|
+
const normalized = path.posix.normalize(portable);
|
|
17
|
+
if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
|
|
18
|
+
throw new Error(`${label} escapes the task target: ${value}`);
|
|
19
|
+
}
|
|
20
|
+
return normalized === "." ? "" : normalized.replace(/^\.\//, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizePathList(value, label) {
|
|
24
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
|
25
|
+
const normalized = value.map((item) => normalizePath(item, label));
|
|
26
|
+
if (new Set(normalized).size !== normalized.length) throw new Error(`${label} must not contain duplicate paths`);
|
|
27
|
+
return normalized.sort();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pathsOverlap(left, right) {
|
|
31
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function overlapPath(left, right) {
|
|
35
|
+
return left.length <= right.length ? left : right;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function assertBriefPathBoundaries(brief) {
|
|
39
|
+
for (const allowed of brief.allowedPaths) {
|
|
40
|
+
for (const readOnly of brief.readOnlyPaths) {
|
|
41
|
+
if (pathsOverlap(allowed, readOnly)) {
|
|
42
|
+
throw new Error(`allowed and read-only paths overlap: ${allowed} / ${readOnly}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function validateTaskBrief(brief, packageRoot) {
|
|
49
|
+
assertSecretFree(brief);
|
|
50
|
+
const normalized = {
|
|
51
|
+
...brief,
|
|
52
|
+
schemaVersion: DELEGATION_SCHEMA_VERSION,
|
|
53
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
54
|
+
allowedPaths: normalizePathList(brief.allowedPaths, "allowedPaths"),
|
|
55
|
+
readOnlyPaths: normalizePathList(brief.readOnlyPaths, "readOnlyPaths"),
|
|
56
|
+
dependencies: [...(brief.dependencies ?? [])].sort(),
|
|
57
|
+
constraints: [...(brief.constraints ?? [])],
|
|
58
|
+
requiredGuides: [...(brief.requiredGuides ?? [])].sort(),
|
|
59
|
+
verification: [...(brief.verification ?? [])],
|
|
60
|
+
authority: [...(brief.authority ?? [])],
|
|
61
|
+
deliverables: [...(brief.deliverables ?? [])],
|
|
62
|
+
executionMode: brief.executionMode ?? "inline",
|
|
63
|
+
};
|
|
64
|
+
const schema = await readSchema("task-brief", packageRoot);
|
|
65
|
+
assertSchema(normalized, schema, "delegation task brief");
|
|
66
|
+
for (const guide of normalized.requiredGuides) {
|
|
67
|
+
if (!GUIDE_IDS.includes(guide)) throw new Error(`Task brief contains unknown guide: ${guide}`);
|
|
68
|
+
}
|
|
69
|
+
if (normalized.dependencies.includes(normalized.taskId)) {
|
|
70
|
+
throw new Error("Task brief cannot depend on itself");
|
|
71
|
+
}
|
|
72
|
+
assertBriefPathBoundaries(normalized);
|
|
73
|
+
return normalized;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function validateDelegatedResult(result, packageRoot) {
|
|
77
|
+
assertSecretFree(result);
|
|
78
|
+
const schema = await readSchema("delegated-result", packageRoot);
|
|
79
|
+
const validated = assertSchema(result, schema, "delegated result");
|
|
80
|
+
assertEvidenceList(validated.evidence ?? [], "delegated-result.evidence");
|
|
81
|
+
if (validated.status === "complete" && !(validated.evidence ?? []).some((item) => ["OBSERVED", "INFERRED"].includes(item.kind))) {
|
|
82
|
+
throw new Error("complete delegated result requires verification evidence");
|
|
83
|
+
}
|
|
84
|
+
return validated;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function findOwnershipConflicts(briefs) {
|
|
88
|
+
const conflicts = [];
|
|
89
|
+
const ordered = [...briefs].sort((left, right) => left.taskId.localeCompare(right.taskId));
|
|
90
|
+
for (let leftIndex = 0; leftIndex < ordered.length; leftIndex += 1) {
|
|
91
|
+
for (let rightIndex = leftIndex + 1; rightIndex < ordered.length; rightIndex += 1) {
|
|
92
|
+
const left = ordered[leftIndex];
|
|
93
|
+
const right = ordered[rightIndex];
|
|
94
|
+
const leftPaths = (left.allowedPaths ?? []).map((value) => normalizePath(value, "allowedPaths"));
|
|
95
|
+
const rightPaths = (right.allowedPaths ?? []).map((value) => normalizePath(value, "allowedPaths"));
|
|
96
|
+
const leftReadOnly = (left.readOnlyPaths ?? []).map((value) => normalizePath(value, "readOnlyPaths"));
|
|
97
|
+
const rightReadOnly = (right.readOnlyPaths ?? []).map((value) => normalizePath(value, "readOnlyPaths"));
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
const addConflict = (type, leftPath, rightPath) => {
|
|
100
|
+
if (!pathsOverlap(leftPath, rightPath)) return;
|
|
101
|
+
const pathValue = overlapPath(leftPath, rightPath);
|
|
102
|
+
const key = `${type}:${pathValue}`;
|
|
103
|
+
if (seen.has(key)) return;
|
|
104
|
+
seen.add(key);
|
|
105
|
+
conflicts.push({ taskIds: [left.taskId, right.taskId], type, path: pathValue });
|
|
106
|
+
};
|
|
107
|
+
for (const leftPath of leftPaths) {
|
|
108
|
+
for (const rightPath of rightPaths) addConflict("WRITE_WRITE", leftPath, rightPath);
|
|
109
|
+
for (const rightPath of rightReadOnly) addConflict("WRITE_READ", leftPath, rightPath);
|
|
110
|
+
}
|
|
111
|
+
for (const rightPath of rightPaths) {
|
|
112
|
+
for (const leftPath of leftReadOnly) addConflict("WRITE_READ", rightPath, leftPath);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
conflicts.sort((left, right) => left.taskIds.join("\0").localeCompare(right.taskIds.join("\0"))
|
|
117
|
+
|| left.type.localeCompare(right.type)
|
|
118
|
+
|| left.path.localeCompare(right.path));
|
|
119
|
+
return { conflicts };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function findUnknownDependencies(briefs) {
|
|
123
|
+
const known = new Set(briefs.map((brief) => brief.taskId));
|
|
124
|
+
return briefs
|
|
125
|
+
.flatMap((brief) => (Array.isArray(brief.dependencies) ? brief.dependencies : [])
|
|
126
|
+
.filter((dependency) => !known.has(dependency))
|
|
127
|
+
.map((dependency) => ({ taskId: brief.taskId, dependency })))
|
|
128
|
+
.sort((left, right) => left.taskId.localeCompare(right.taskId) || left.dependency.localeCompare(right.dependency));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function findDependencyCycles(briefs) {
|
|
132
|
+
const graph = new Map(briefs.map((brief) => [brief.taskId, [...(Array.isArray(brief.dependencies) ? brief.dependencies : [])].sort()]));
|
|
133
|
+
const visiting = new Set();
|
|
134
|
+
const visited = new Set();
|
|
135
|
+
const stack = [];
|
|
136
|
+
const cycles = [];
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
|
|
139
|
+
function visit(taskId) {
|
|
140
|
+
if (visiting.has(taskId)) {
|
|
141
|
+
const cycle = [...stack.slice(stack.indexOf(taskId)), taskId];
|
|
142
|
+
const key = cycle.join("->");
|
|
143
|
+
if (!seen.has(key)) {
|
|
144
|
+
seen.add(key);
|
|
145
|
+
cycles.push(cycle);
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (visited.has(taskId)) return;
|
|
150
|
+
visiting.add(taskId);
|
|
151
|
+
stack.push(taskId);
|
|
152
|
+
for (const dependency of graph.get(taskId) ?? []) {
|
|
153
|
+
if (graph.has(dependency)) visit(dependency);
|
|
154
|
+
}
|
|
155
|
+
stack.pop();
|
|
156
|
+
visiting.delete(taskId);
|
|
157
|
+
visited.add(taskId);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const taskId of [...graph.keys()].sort()) visit(taskId);
|
|
161
|
+
return cycles;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function setError(errors, code, message, details = {}) {
|
|
165
|
+
errors.push({ code, message, ...details });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function validateDelegationSet(briefs) {
|
|
169
|
+
const errors = [];
|
|
170
|
+
if (!Array.isArray(briefs)) {
|
|
171
|
+
return {
|
|
172
|
+
status: "INVALID",
|
|
173
|
+
errors: [{ code: "INVALID_SET", message: "Delegation set must be an array" }],
|
|
174
|
+
conflicts: [],
|
|
175
|
+
cycles: [],
|
|
176
|
+
unknownDependencies: [],
|
|
177
|
+
integrationOwner: null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const ordered = [...briefs].sort((left, right) => String(left?.taskId).localeCompare(String(right?.taskId)));
|
|
182
|
+
const taskIds = new Set();
|
|
183
|
+
for (const brief of ordered) {
|
|
184
|
+
if (!brief || typeof brief !== "object" || Array.isArray(brief)) {
|
|
185
|
+
setError(errors, "INVALID_TASK_BRIEF", "Each delegation item must be an object");
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (typeof brief.taskId !== "string" || !brief.taskId) {
|
|
189
|
+
setError(errors, "INVALID_TASK_ID", "Each task brief requires a taskId");
|
|
190
|
+
} else if (taskIds.has(brief.taskId)) {
|
|
191
|
+
setError(errors, "DUPLICATE_TASK_ID", `Task ID is duplicated: ${brief.taskId}`, { taskId: brief.taskId });
|
|
192
|
+
} else {
|
|
193
|
+
taskIds.add(brief.taskId);
|
|
194
|
+
}
|
|
195
|
+
if (brief.schemaVersion !== DELEGATION_SCHEMA_VERSION || brief.protocolVersion !== PROTOCOL_VERSION) {
|
|
196
|
+
setError(errors, "UNSUPPORTED_PROTOCOL_VERSION", `Unsupported task brief version: ${brief.taskId}`, { taskId: brief.taskId });
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
assertSecretFree(brief);
|
|
200
|
+
const allowedPaths = normalizePathList(brief.allowedPaths, "allowedPaths");
|
|
201
|
+
const readOnlyPaths = normalizePathList(brief.readOnlyPaths, "readOnlyPaths");
|
|
202
|
+
assertBriefPathBoundaries({ allowedPaths, readOnlyPaths });
|
|
203
|
+
if (!Array.isArray(brief.verification) || brief.verification.length === 0) {
|
|
204
|
+
setError(errors, "MISSING_VERIFICATION", `Task brief requires verification: ${brief.taskId}`, { taskId: brief.taskId });
|
|
205
|
+
}
|
|
206
|
+
if (!Array.isArray(brief.authority) || brief.authority.length === 0) {
|
|
207
|
+
setError(errors, "MISSING_AUTHORITY", `Task brief requires authority: ${brief.taskId}`, { taskId: brief.taskId });
|
|
208
|
+
}
|
|
209
|
+
for (const guide of brief.requiredGuides ?? []) {
|
|
210
|
+
if (!GUIDE_IDS.includes(guide)) {
|
|
211
|
+
setError(errors, "UNKNOWN_GUIDE", `Task brief contains unknown guide: ${guide}`, { taskId: brief.taskId, guide });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
setError(errors, "INVALID_TASK_BRIEF", error.message, { taskId: brief.taskId ?? null });
|
|
216
|
+
}
|
|
217
|
+
if (Array.isArray(brief.dependencies) && brief.dependencies.includes(brief.taskId)) {
|
|
218
|
+
setError(errors, "SELF_DEPENDENCY", `Task brief cannot depend on itself: ${brief.taskId}`, { taskId: brief.taskId });
|
|
219
|
+
}
|
|
220
|
+
if (Array.isArray(brief.dependencies) && new Set(brief.dependencies).size !== brief.dependencies.length) {
|
|
221
|
+
setError(errors, "DUPLICATE_DEPENDENCY", `Task brief dependencies must be unique: ${brief.taskId}`, { taskId: brief.taskId });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const unknownDependencies = findUnknownDependencies(ordered);
|
|
226
|
+
for (const item of unknownDependencies) {
|
|
227
|
+
setError(errors, "UNKNOWN_DEPENDENCY", `Unknown dependency ${item.dependency} for ${item.taskId}`, item);
|
|
228
|
+
}
|
|
229
|
+
const cycles = findDependencyCycles(ordered);
|
|
230
|
+
for (const cycle of cycles) {
|
|
231
|
+
setError(errors, "DEPENDENCY_CYCLE", `Dependency cycle: ${cycle.join(" -> ")}`, { cycle });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let conflicts = [];
|
|
235
|
+
if (!errors.some((error) => error.code === "INVALID_TASK_BRIEF")) {
|
|
236
|
+
try {
|
|
237
|
+
conflicts = findOwnershipConflicts(ordered).conflicts;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
setError(errors, "INVALID_TASK_BRIEF", error.message);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const parentIds = [...new Set(ordered.map((brief) => brief?.parentTaskId).filter(Boolean))].sort();
|
|
243
|
+
if (parentIds.length > 1) {
|
|
244
|
+
setError(errors, "MULTIPLE_INTEGRATION_OWNERS", "All child tasks in a delegation set must share one parent integration owner");
|
|
245
|
+
}
|
|
246
|
+
errors.sort((left, right) => left.code.localeCompare(right.code)
|
|
247
|
+
|| String(left.taskId).localeCompare(String(right.taskId))
|
|
248
|
+
|| left.message.localeCompare(right.message));
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
status: errors.length > 0 ? "INVALID" : conflicts.length > 0 ? "SERIAL_REQUIRED" : "PARALLEL_SAFE",
|
|
252
|
+
errors,
|
|
253
|
+
conflicts,
|
|
254
|
+
cycles,
|
|
255
|
+
unknownDependencies,
|
|
256
|
+
integrationOwner: parentIds.length === 1 ? parentIds[0] : null,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function isIndependentReview({ implementerId, reviewerId, reviewType }) {
|
|
261
|
+
return reviewType === "independent"
|
|
262
|
+
&& typeof implementerId === "string"
|
|
263
|
+
&& typeof reviewerId === "string"
|
|
264
|
+
&& implementerId !== reviewerId;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function selectExecutionMode({ subagentsAvailable }) {
|
|
268
|
+
return subagentsAvailable ? "delegated" : "inline";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function normalizeDelegatedResult(result) {
|
|
272
|
+
return {
|
|
273
|
+
schemaVersion: DELEGATION_SCHEMA_VERSION,
|
|
274
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
275
|
+
taskId: result.taskId,
|
|
276
|
+
status: result.status,
|
|
277
|
+
changes: [...(result.changes ?? [])],
|
|
278
|
+
verification: [...(result.verification ?? [])],
|
|
279
|
+
evidence: [...(result.evidence ?? [])],
|
|
280
|
+
openFindings: [...(result.openFindings ?? [])],
|
|
281
|
+
limitations: [...(result.limitations ?? [])],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const EVIDENCE_KINDS = Object.freeze([
|
|
2
|
+
"OBSERVED",
|
|
3
|
+
"INFERRED",
|
|
4
|
+
"NOT_VERIFIED",
|
|
5
|
+
"BLOCKED",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export class EvidenceError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "EvidenceError";
|
|
12
|
+
this.code = "EVIDENCE_INVALID";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function assertNonEmptyString(value, label) {
|
|
17
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
18
|
+
throw new EvidenceError(`${label} must be a non-empty string`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createEvidence(input, source, result, details) {
|
|
23
|
+
const value = typeof input === "string"
|
|
24
|
+
? { kind: input, source, result, ...(details === undefined ? {} : { details }) }
|
|
25
|
+
: input;
|
|
26
|
+
assertEvidence(value);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function assertEvidence(value, label = "evidence") {
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
32
|
+
throw new EvidenceError(`${label} must be an object`);
|
|
33
|
+
}
|
|
34
|
+
if (!EVIDENCE_KINDS.includes(value.kind)) {
|
|
35
|
+
throw new EvidenceError(`${label}.kind must be one of ${EVIDENCE_KINDS.join(", ")}`);
|
|
36
|
+
}
|
|
37
|
+
assertNonEmptyString(value.source, `${label}.source`);
|
|
38
|
+
assertNonEmptyString(value.result, `${label}.result`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function assertEvidenceList(value, label = "evidence") {
|
|
43
|
+
if (!Array.isArray(value)) throw new EvidenceError(`${label} must be an array`);
|
|
44
|
+
value.forEach((item, index) => assertEvidence(item, `${label}[${index}]`));
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function evidenceMatches(evidence, terms) {
|
|
49
|
+
const normalizedTerms = (Array.isArray(terms) ? terms : [terms])
|
|
50
|
+
.filter((term) => typeof term === "string" && term.length > 0)
|
|
51
|
+
.map((term) => term.toLowerCase());
|
|
52
|
+
return (evidence ?? []).some((item) => {
|
|
53
|
+
const haystack = `${item.source} ${item.result}`.toLowerCase();
|
|
54
|
+
return normalizedTerms.some((term) => haystack.includes(term));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
readFile,
|
|
7
|
+
realpath,
|
|
8
|
+
rename,
|
|
9
|
+
unlink,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
export function ensureWithin(root, relativePath) {
|
|
15
|
+
if (path.isAbsolute(relativePath)) {
|
|
16
|
+
throw new Error(`Path must remain inside target directory: ${relativePath}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const normalized = path.normalize(relativePath);
|
|
20
|
+
if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
|
|
21
|
+
throw new Error(`Path escapes target directory: ${relativePath}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return path.join(root, normalized);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function assertSafePath(root, relativePath) {
|
|
28
|
+
const destination = ensureWithin(root, relativePath);
|
|
29
|
+
const absoluteRoot = path.resolve(root);
|
|
30
|
+
const rootInfo = await lstat(absoluteRoot);
|
|
31
|
+
if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) {
|
|
32
|
+
throw new Error(`Target directory must not be a symlink: ${absoluteRoot}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let current = absoluteRoot;
|
|
36
|
+
const segments = path.relative(absoluteRoot, destination).split(path.sep).filter(Boolean);
|
|
37
|
+
for (const segment of segments) {
|
|
38
|
+
current = path.join(current, segment);
|
|
39
|
+
try {
|
|
40
|
+
const info = await lstat(current);
|
|
41
|
+
if (info.isSymbolicLink()) {
|
|
42
|
+
throw new Error(`Path uses a symlink inside target directory: ${relativePath}`);
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code === "ENOENT") break;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let existing = destination;
|
|
51
|
+
while (true) {
|
|
52
|
+
try {
|
|
53
|
+
const info = await lstat(existing);
|
|
54
|
+
if (info.isSymbolicLink()) {
|
|
55
|
+
throw new Error(`Path uses a symlink inside target directory: ${relativePath}`);
|
|
56
|
+
}
|
|
57
|
+
const resolvedRoot = await realpath(absoluteRoot);
|
|
58
|
+
const resolvedExisting = await realpath(existing);
|
|
59
|
+
const relativeResolved = path.relative(resolvedRoot, resolvedExisting);
|
|
60
|
+
if (relativeResolved === ".." || relativeResolved.startsWith(`..${path.sep}`) || path.isAbsolute(relativeResolved)) {
|
|
61
|
+
throw new Error(`Path escapes target directory: ${relativePath}`);
|
|
62
|
+
}
|
|
63
|
+
return destination;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error.code !== "ENOENT") throw error;
|
|
66
|
+
const parent = path.dirname(existing);
|
|
67
|
+
if (parent === existing) throw new Error(`Path does not resolve inside target directory: ${relativePath}`);
|
|
68
|
+
existing = parent;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function resolveTarget(cwd, requestedPath = ".") {
|
|
74
|
+
const target = path.resolve(cwd, requestedPath);
|
|
75
|
+
let targetStat;
|
|
76
|
+
try {
|
|
77
|
+
targetStat = await lstat(target);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.code === "ENOENT") {
|
|
80
|
+
throw new Error(`Target directory does not exist: ${target}`);
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!targetStat.isDirectory() || targetStat.isSymbolicLink()) {
|
|
86
|
+
throw new Error(`Target path is not a directory: ${target}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return target;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function fileExists(filePath) {
|
|
93
|
+
try {
|
|
94
|
+
await access(filePath);
|
|
95
|
+
return true;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error.code === "ENOENT") return false;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function readBytes(filePath) {
|
|
103
|
+
return readFile(filePath);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function writeFileAtomic(filePath, bytes, { dryRun = false } = {}) {
|
|
107
|
+
if (dryRun) return;
|
|
108
|
+
|
|
109
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
110
|
+
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
|
|
111
|
+
try {
|
|
112
|
+
await writeFile(temporaryPath, bytes, { mode: 0o644 });
|
|
113
|
+
await rename(temporaryPath, filePath);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
try {
|
|
116
|
+
await unlink(temporaryPath);
|
|
117
|
+
} catch {
|
|
118
|
+
// Preserve the original filesystem error.
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|