@tea-agent/loop-agent 0.2.1 → 0.4.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/AGENTS.md +91 -87
- package/CHANGELOG.md +89 -52
- package/README.md +195 -180
- package/bin/agent-worker.js +22 -0
- package/bin/loop-agent.js +21 -21
- package/dist/application/dag/args.js +6 -0
- package/dist/application/dag/generate-task-dag.js +2 -0
- package/dist/application/dag/run-dag.js +3 -0
- package/dist/application/dag/validate-dag.js +40 -0
- package/dist/cli/command-definitions.js +2 -2
- package/dist/cli/program.js +24 -4
- package/dist/commands/init.js +1011 -459
- package/dist/commands/loop-benchmark.js +11 -11
- package/dist/commands/pi-reuse-benchmark.js +16 -16
- package/dist/executors/cursor-executor.js +1 -1
- package/dist/executors/dag-pi-executor.js +8 -1
- package/dist/task/runtime.js +27 -27
- package/dist/worker/cli.js +119 -0
- package/dist/worker/loop-agent/command-result.js +1 -0
- package/dist/worker/loop-agent/loop-agent-client.js +105 -0
- package/dist/worker/loop-agent/parse-json.js +14 -0
- package/dist/worker/materialize/harness-task-materializer.js +157 -0
- package/dist/worker/pool/failure-routing.js +98 -0
- package/dist/worker/pool/run-store.js +117 -0
- package/dist/worker/pool/types.js +1 -0
- package/dist/worker/preflight.js +108 -0
- package/dist/worker/profile-mapping.js +76 -0
- package/dist/worker/progress-reporter.js +81 -0
- package/dist/worker/report/morning-report.js +69 -0
- package/dist/worker/repos/repo-resolver.js +23 -0
- package/dist/worker/run-task/run-task.js +359 -0
- package/dist/worker/runner/run-ready.js +216 -0
- package/dist/worker/task-graph/acceptance-schema.js +25 -0
- package/dist/worker/task-graph/ready-queue.js +23 -0
- package/dist/worker/task-graph/task-graph-schema.js +28 -0
- package/dist/worker/task-graph/types.js +1 -0
- package/dist/worker/task-graph/validate.js +188 -0
- package/dist/worker/task-spec/complexity-mapping.js +8 -0
- package/dist/worker/task-spec/schema.js +116 -0
- package/dist/worker/task-spec/types.js +1 -0
- package/dist/worker/task-spec/validate.js +352 -0
- package/dist/workflows/dag/canvas-observer.js +275 -275
- package/dist/workflows/dag/dynamic-runtime/loop-until.js +2 -1
- package/dist/workflows/dag/dynamic-runtime/map.js +1 -0
- package/dist/workflows/dag/init-hybrid.js +3 -3
- package/dist/workflows/dag/skills.js +3 -3
- package/dist/workflows/dag/types.js +2 -0
- package/dist/workflows/dynamic/compile.js +11 -0
- package/dist/workflows/dynamic/spec.js +1 -0
- package/docs/README.md +72 -65
- package/docs/agent-dag-recovery-playbook.md +184 -184
- package/docs/agent-dag-runner.md +42 -40
- package/docs/architecture/runtime-boundaries.md +147 -147
- package/docs/cursor-executor-usage.md +25 -25
- package/docs/decisions/README.md +3 -3
- package/docs/design/README.md +36 -36
- package/docs/development-principles.md +73 -71
- package/docs/dynamic-workflow-dag-engine-roadmap.md +1749 -1749
- package/docs/exec-plans/README.md +6 -6
- package/docs/exec-plans/active/README.md +7 -10
- package/docs/exec-plans/completed/README.md +19 -9
- package/docs/feature-workflow.md +186 -186
- package/docs/harness-methodology-debugging.md +153 -153
- package/docs/harness-methodology-tdd.md +130 -130
- package/docs/harness-methodology-verification.md +27 -27
- package/docs/init-surface.manifest.json +175 -0
- package/docs/loop-agent-harness.md +42 -42
- package/docs/production-readiness.md +96 -96
- package/docs/progress/README.md +3 -3
- package/docs/reports/README.md +5 -5
- package/docs/skills/README.md +6 -0
- package/docs/skills/vetted-skill-registry.md +26 -0
- package/docs/templates/adr.md +60 -60
- package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -94
- package/docs/templates/agent-dag-decision-envelope.schema.json +213 -213
- package/docs/templates/agent-dag-decision-gate-dogfood-report.md +117 -117
- package/docs/templates/agent-dag-decision-gate.prompt.md +246 -246
- package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -98
- package/docs/templates/agent-dag-report.schema.json +454 -454
- package/docs/templates/agent-dag-review-verdict.prompt.md +68 -68
- package/docs/templates/agent-dag.base.json +195 -195
- package/docs/templates/agent-dag.final-verification.json +190 -190
- package/docs/templates/agent-dag.schema.json +316 -316
- package/docs/templates/agent-dag.supervised-implementation.json +500 -500
- package/docs/templates/exec-plan.md +64 -64
- package/docs/templates/feature-spec.md +53 -53
- package/docs/templates/hybrid-dag.json +193 -193
- package/docs/templates/init-evolution-review.md +33 -0
- package/docs/templates/production-readiness-checklist.md +57 -57
- package/docs/templates/progress-log.md +17 -17
- package/docs/templates/project-start-checklist.md +9 -9
- package/docs/templates/qa-report.md +48 -48
- package/docs/templates/sprint-contract.md +29 -29
- package/docs/verification-matrix.md +41 -41
- package/examples/decision-gate-agent-dag.json +123 -123
- package/examples/example-dag.json +51 -51
- package/examples/hybrid-loop-agent-dag.json +194 -194
- package/harness.json +94 -92
- package/package.json +66 -62
- package/skills/ai-engineering-context/SKILL.md +48 -48
- package/skills/code-review-core/SKILL.md +20 -0
- package/skills/codebase-scout/SKILL.md +19 -0
- package/skills/init-capability-evolution/SKILL.md +69 -0
- package/skills/loop-agent/SKILL.md +147 -145
- package/skills/loop-agent/references/README.md +67 -67
- package/skills/loop-agent/references/command-reference.md +403 -357
- package/skills/loop-agent/references/harness-policy.md +259 -258
- package/skills/loop-agent/references/hybrid-dag.md +216 -216
- package/skills/loop-agent/references/learned/README.md +21 -21
- package/skills/loop-agent/references/long-running-loop.md +59 -59
- package/skills/loop-agent/references/model-routing.md +36 -36
- package/skills/loop-agent/references/multi-worktree.md +54 -54
- package/skills/loop-agent/references/one-shot-runs.md +85 -85
- package/skills/loop-agent/references/orchestrator-and-interventions.md +169 -169
- package/skills/loop-agent/references/pi-prompt.md +23 -23
- package/skills/loop-agent/references/pi-subagent-assisted-mode.md +81 -81
- package/skills/loop-agent/references/post-implementation-and-patterns.md +44 -44
- package/skills/loop-agent/references/task-workflow.md +84 -84
- package/skills/loop-agent/references/verification-and-failure-handling.md +128 -128
- package/skills/requesting-code-review/SKILL.md +101 -101
- package/skills/requesting-code-review/code-reviewer.md +168 -168
- package/skills/systematic-debugging/CREATION-LOG.md +119 -119
- package/skills/systematic-debugging/SKILL.md +296 -296
- package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
- package/skills/systematic-debugging/condition-based-waiting.md +115 -115
- package/skills/systematic-debugging/defense-in-depth.md +122 -122
- package/skills/systematic-debugging/find-polluter.sh +63 -63
- package/skills/systematic-debugging/root-cause-tracing.md +169 -169
- package/skills/systematic-debugging/test-academic.md +14 -14
- package/skills/systematic-debugging/test-pressure-1.md +58 -58
- package/skills/systematic-debugging/test-pressure-2.md +68 -68
- package/skills/systematic-debugging/test-pressure-3.md +69 -69
- package/skills/test-driven-development/SKILL.md +20 -0
- package/skills/verification-before-completion/SKILL.md +154 -154
- package/skills/webapp-testing/SKILL.md +19 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { access, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import { taskSpecSchema } from "../task-spec/schema.js";
|
|
5
|
+
import { acceptanceSpecSchema } from "./acceptance-schema.js";
|
|
6
|
+
import { computeReadyQueue } from "./ready-queue.js";
|
|
7
|
+
import { taskGraphSpecSchema } from "./task-graph-schema.js";
|
|
8
|
+
export async function validateFeatureTaskGraph(featureDir, options = {}) {
|
|
9
|
+
const errors = [];
|
|
10
|
+
const acceptance = await loadAcceptanceSpec(featureDir, options, errors);
|
|
11
|
+
const graph = await loadTaskGraphSpec(featureDir, options, errors);
|
|
12
|
+
if (!acceptance || !graph) {
|
|
13
|
+
return result("", acceptance, graph, errors);
|
|
14
|
+
}
|
|
15
|
+
checkDuplicateNodeIds(graph, errors);
|
|
16
|
+
checkAcceptanceTaskRefs(acceptance, graph, errors);
|
|
17
|
+
checkUnknownDependencies(graph, errors);
|
|
18
|
+
checkCycles(graph, errors);
|
|
19
|
+
await checkTaskFiles(featureDir, graph, errors);
|
|
20
|
+
return result(graph.feature_id, acceptance, graph, errors);
|
|
21
|
+
}
|
|
22
|
+
async function loadAcceptanceSpec(featureDir, options, errors) {
|
|
23
|
+
const raw = options.acceptanceOverride ??
|
|
24
|
+
YAML.parse(await readFile(path.join(featureDir, "acceptance.yaml"), "utf-8"));
|
|
25
|
+
const parsed = acceptanceSpecSchema.safeParse(raw);
|
|
26
|
+
if (!parsed.success) {
|
|
27
|
+
errors.push({
|
|
28
|
+
code: "acceptance-schema-invalid",
|
|
29
|
+
message: parsed.error.issues.map((issue) => issue.message).join("; "),
|
|
30
|
+
path: "acceptance.yaml",
|
|
31
|
+
});
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return parsed.data;
|
|
35
|
+
}
|
|
36
|
+
async function loadTaskGraphSpec(featureDir, options, errors) {
|
|
37
|
+
const raw = options.graphOverride ??
|
|
38
|
+
YAML.parse(await readFile(path.join(featureDir, "tasks", "task-graph.yaml"), "utf-8"));
|
|
39
|
+
const parsed = taskGraphSpecSchema.safeParse(raw);
|
|
40
|
+
if (!parsed.success) {
|
|
41
|
+
errors.push({
|
|
42
|
+
code: "task-graph-schema-invalid",
|
|
43
|
+
message: parsed.error.issues.map((issue) => issue.message).join("; "),
|
|
44
|
+
path: "tasks/task-graph.yaml",
|
|
45
|
+
});
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
return parsed.data;
|
|
49
|
+
}
|
|
50
|
+
function checkDuplicateNodeIds(graph, errors) {
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
for (const node of graph.nodes) {
|
|
53
|
+
if (seen.has(node.id)) {
|
|
54
|
+
errors.push({
|
|
55
|
+
code: "duplicate-node-id",
|
|
56
|
+
message: `duplicate task graph node id: ${node.id}`,
|
|
57
|
+
path: `nodes.${node.id}`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
seen.add(node.id);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function checkAcceptanceTaskRefs(acceptance, graph, errors) {
|
|
64
|
+
const nodeIds = new Set(graph.nodes.map((node) => node.id));
|
|
65
|
+
for (const item of acceptance.acceptance) {
|
|
66
|
+
for (const taskRef of item.verification.expected_task_refs) {
|
|
67
|
+
if (!nodeIds.has(taskRef)) {
|
|
68
|
+
errors.push({
|
|
69
|
+
code: "acceptance-task-ref-missing",
|
|
70
|
+
message: `acceptance ${item.id} references missing task ${taskRef}`,
|
|
71
|
+
path: `acceptance.${item.id}.verification.expected_task_refs`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function checkUnknownDependencies(graph, errors) {
|
|
78
|
+
const nodeIds = new Set(graph.nodes.map((node) => node.id));
|
|
79
|
+
for (const node of graph.nodes) {
|
|
80
|
+
for (const dependency of node.depends_on) {
|
|
81
|
+
if (!nodeIds.has(dependency)) {
|
|
82
|
+
errors.push({
|
|
83
|
+
code: "unknown-dependency",
|
|
84
|
+
message: `${node.id} depends on unknown task ${dependency}`,
|
|
85
|
+
path: `nodes.${node.id}.depends_on`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function checkCycles(graph, errors) {
|
|
92
|
+
const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
93
|
+
const visiting = new Set();
|
|
94
|
+
const visited = new Set();
|
|
95
|
+
const visit = (nodeId, trail) => {
|
|
96
|
+
if (visiting.has(nodeId)) {
|
|
97
|
+
errors.push({
|
|
98
|
+
code: "cycle-detected",
|
|
99
|
+
message: `cycle detected: ${[...trail, nodeId].join(" -> ")}`,
|
|
100
|
+
path: `nodes.${nodeId}.depends_on`,
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (visited.has(nodeId))
|
|
105
|
+
return;
|
|
106
|
+
const node = nodes.get(nodeId);
|
|
107
|
+
if (!node)
|
|
108
|
+
return;
|
|
109
|
+
visiting.add(nodeId);
|
|
110
|
+
for (const dependency of node.depends_on)
|
|
111
|
+
visit(dependency, [...trail, nodeId]);
|
|
112
|
+
visiting.delete(nodeId);
|
|
113
|
+
visited.add(nodeId);
|
|
114
|
+
};
|
|
115
|
+
for (const node of graph.nodes)
|
|
116
|
+
visit(node.id, []);
|
|
117
|
+
}
|
|
118
|
+
async function checkTaskFiles(featureDir, graph, errors) {
|
|
119
|
+
for (const node of graph.nodes) {
|
|
120
|
+
const taskPath = path.join(featureDir, "tasks", node.task);
|
|
121
|
+
if (!(await exists(taskPath))) {
|
|
122
|
+
errors.push({
|
|
123
|
+
code: "task-file-missing",
|
|
124
|
+
message: `task file does not exist: ${node.task}`,
|
|
125
|
+
path: `nodes.${node.id}.task`,
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const parsedTask = taskSpecSchema.safeParse(YAML.parse(await readFile(taskPath, "utf-8")));
|
|
130
|
+
if (!parsedTask.success) {
|
|
131
|
+
errors.push({
|
|
132
|
+
code: "task-spec-schema-invalid",
|
|
133
|
+
message: `task file is not a valid TaskSpec: ${node.task}`,
|
|
134
|
+
path: `nodes.${node.id}.task`,
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const taskSpec = parsedTask.data;
|
|
139
|
+
if (taskSpec.id !== node.id) {
|
|
140
|
+
errors.push({
|
|
141
|
+
code: "task-id-mismatch",
|
|
142
|
+
message: `graph node ${node.id} points at TaskSpec ${taskSpec.id}`,
|
|
143
|
+
path: `nodes.${node.id}.id`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (taskSpec.type !== node.type) {
|
|
147
|
+
errors.push({
|
|
148
|
+
code: "task-type-mismatch",
|
|
149
|
+
message: `graph node ${node.id} type ${node.type} differs from TaskSpec ${taskSpec.type}`,
|
|
150
|
+
path: `nodes.${node.id}.type`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (!sameStringSet(taskSpec.depends_on, node.depends_on)) {
|
|
154
|
+
errors.push({
|
|
155
|
+
code: "task-depends-on-mismatch",
|
|
156
|
+
message: `graph node ${node.id} depends_on differs from TaskSpec`,
|
|
157
|
+
path: `nodes.${node.id}.depends_on`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function result(featureId, acceptance, graph, errors) {
|
|
163
|
+
return {
|
|
164
|
+
ok: errors.length === 0,
|
|
165
|
+
featureId,
|
|
166
|
+
summary: {
|
|
167
|
+
acceptanceCount: acceptance?.acceptance.length ?? 0,
|
|
168
|
+
nodeCount: graph?.nodes.length ?? 0,
|
|
169
|
+
readyWithoutState: graph ? computeReadyQueue(graph, {}) : [],
|
|
170
|
+
},
|
|
171
|
+
errors,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async function exists(filePath) {
|
|
175
|
+
try {
|
|
176
|
+
await access(filePath);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function sameStringSet(left, right) {
|
|
184
|
+
if (left.length !== right.length)
|
|
185
|
+
return false;
|
|
186
|
+
const rightSet = new Set(right);
|
|
187
|
+
return left.every((value) => rightSet.has(value));
|
|
188
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { verifyModeSchema, verifyPresetSchema, verifyQuotaSchema, } from "../../task/config-types.js";
|
|
3
|
+
export const taskSpecTypeSchema = z.enum([
|
|
4
|
+
"architecture",
|
|
5
|
+
"backend-feature",
|
|
6
|
+
"frontend-feature",
|
|
7
|
+
"bugfix",
|
|
8
|
+
"ci-fix",
|
|
9
|
+
"doc-update",
|
|
10
|
+
"qa-analysis",
|
|
11
|
+
"qa-casegen",
|
|
12
|
+
"qa-testcode",
|
|
13
|
+
"qa-execute",
|
|
14
|
+
"qa-failure-analysis",
|
|
15
|
+
"reviewer-gate",
|
|
16
|
+
"fix-from-failure",
|
|
17
|
+
]);
|
|
18
|
+
export const taskSpecRiskLevelSchema = z.enum(["low", "medium", "high"]);
|
|
19
|
+
const verifyCommandSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
id: z.string().min(1),
|
|
22
|
+
label: z.string().min(1),
|
|
23
|
+
command: z.string().min(1),
|
|
24
|
+
required: z.boolean().optional().default(true),
|
|
25
|
+
timeout_ms: z.number().int().positive().optional(),
|
|
26
|
+
})
|
|
27
|
+
.strict();
|
|
28
|
+
export const taskSpecSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
schema_version: z.literal(1),
|
|
31
|
+
id: z.string().min(1),
|
|
32
|
+
feature_id: z.string().min(1),
|
|
33
|
+
title: z.string().min(1),
|
|
34
|
+
description: z.string().optional().default(""),
|
|
35
|
+
repo: z
|
|
36
|
+
.object({
|
|
37
|
+
name: z.string().min(1),
|
|
38
|
+
path_ref: z.string().min(1),
|
|
39
|
+
default_branch: z.string().min(1).optional(),
|
|
40
|
+
})
|
|
41
|
+
.strict(),
|
|
42
|
+
type: taskSpecTypeSchema,
|
|
43
|
+
priority: z.enum(["P0", "P1", "P2", "P3"]).optional().default("P2"),
|
|
44
|
+
risk_level: taskSpecRiskLevelSchema,
|
|
45
|
+
depends_on: z.array(z.string().min(1)).optional().default([]),
|
|
46
|
+
source_docs: z
|
|
47
|
+
.object({
|
|
48
|
+
requirement: z.string().min(1),
|
|
49
|
+
acceptance: z.string().min(1),
|
|
50
|
+
design: z.string().min(1).optional(),
|
|
51
|
+
test_plan: z.string().min(1).optional(),
|
|
52
|
+
test_cases: z.string().min(1).optional(),
|
|
53
|
+
})
|
|
54
|
+
.strict(),
|
|
55
|
+
acceptance_refs: z.array(z.string().min(1)).min(1),
|
|
56
|
+
scope: z
|
|
57
|
+
.object({
|
|
58
|
+
goals: z.array(z.string().min(1)).min(1),
|
|
59
|
+
non_goals: z.array(z.string().min(1)).optional().default([]),
|
|
60
|
+
assumptions: z.array(z.string().min(1)).optional().default([]),
|
|
61
|
+
open_questions: z.array(z.string().min(1)).optional().default([]),
|
|
62
|
+
})
|
|
63
|
+
.strict(),
|
|
64
|
+
constraints: z
|
|
65
|
+
.object({
|
|
66
|
+
allowed_paths: z.array(z.string().min(1)).optional().default([]),
|
|
67
|
+
forbidden_paths: z.array(z.string().min(1)).optional().default([]),
|
|
68
|
+
hard_constraints: z.array(z.string().min(1)).optional().default([]),
|
|
69
|
+
})
|
|
70
|
+
.strict(),
|
|
71
|
+
verify: z
|
|
72
|
+
.object({
|
|
73
|
+
preset: verifyPresetSchema,
|
|
74
|
+
mode: verifyModeSchema,
|
|
75
|
+
quota: verifyQuotaSchema.optional().default("full"),
|
|
76
|
+
commands: z.array(verifyCommandSchema).optional().default([]),
|
|
77
|
+
})
|
|
78
|
+
.strict(),
|
|
79
|
+
worker: z
|
|
80
|
+
.object({
|
|
81
|
+
execution_mode: z.enum([
|
|
82
|
+
"dry-run",
|
|
83
|
+
"generate-only",
|
|
84
|
+
"generate-validate",
|
|
85
|
+
"generate-validate-run",
|
|
86
|
+
]),
|
|
87
|
+
max_attempts: z.number().int().positive().optional().default(1),
|
|
88
|
+
timeout_ms: z.number().int().positive().optional(),
|
|
89
|
+
require_clean_worktree: z.boolean().optional().default(true),
|
|
90
|
+
require_human_review: z.boolean().optional().default(true),
|
|
91
|
+
create_worktree: z.boolean().optional().default(false),
|
|
92
|
+
})
|
|
93
|
+
.strict(),
|
|
94
|
+
loop_agent: z
|
|
95
|
+
.object({
|
|
96
|
+
profile_policy: z.literal("mapped"),
|
|
97
|
+
strict_models: z.boolean().optional().default(true),
|
|
98
|
+
strict_governance: z.boolean().optional().default(true),
|
|
99
|
+
no_cursor: z.boolean().optional().default(false),
|
|
100
|
+
max_concurrent: z.number().int().positive().optional().default(1),
|
|
101
|
+
})
|
|
102
|
+
.strict(),
|
|
103
|
+
outputs: z
|
|
104
|
+
.object({
|
|
105
|
+
required: z.array(z.string().min(1)).min(1),
|
|
106
|
+
})
|
|
107
|
+
.strict(),
|
|
108
|
+
failure_policy: z
|
|
109
|
+
.object({
|
|
110
|
+
failed_run_promote: z.boolean().optional().default(false),
|
|
111
|
+
generate_closeout_draft: z.boolean().optional().default(true),
|
|
112
|
+
create_followup_task: z.boolean().optional().default(true),
|
|
113
|
+
})
|
|
114
|
+
.strict(),
|
|
115
|
+
})
|
|
116
|
+
.strict();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { access, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import { resolveLoopAgentProfile } from "../profile-mapping.js";
|
|
5
|
+
import { mapRiskLevelToComplexity } from "./complexity-mapping.js";
|
|
6
|
+
import { taskSpecSchema } from "./schema.js";
|
|
7
|
+
const CODE_WRITING_TYPES = new Set([
|
|
8
|
+
"backend-feature",
|
|
9
|
+
"frontend-feature",
|
|
10
|
+
"bugfix",
|
|
11
|
+
"qa-testcode",
|
|
12
|
+
]);
|
|
13
|
+
const VERIFY_OPTIONAL_TYPES = new Set(["qa-analysis", "reviewer-gate"]);
|
|
14
|
+
const QA_TYPES = new Set([
|
|
15
|
+
"qa-analysis",
|
|
16
|
+
"qa-casegen",
|
|
17
|
+
"qa-testcode",
|
|
18
|
+
"qa-execute",
|
|
19
|
+
"qa-failure-analysis",
|
|
20
|
+
]);
|
|
21
|
+
export async function validateTaskSpec(input, options = {}) {
|
|
22
|
+
const schemaResult = taskSpecSchema.safeParse(input);
|
|
23
|
+
const fallback = fallbackIdentity(input);
|
|
24
|
+
if (!schemaResult.success) {
|
|
25
|
+
const errors = schemaResult.error.issues.map((issue) => ({
|
|
26
|
+
layer: "schema",
|
|
27
|
+
code: "schema-invalid",
|
|
28
|
+
message: issue.message,
|
|
29
|
+
path: issue.path.join("."),
|
|
30
|
+
}));
|
|
31
|
+
const failure = firstFailure(errors, "schema-invalid", "TaskSpec schema validation failed");
|
|
32
|
+
return buildResult({
|
|
33
|
+
taskId: fallback.taskId,
|
|
34
|
+
featureId: fallback.featureId,
|
|
35
|
+
businessProfile: fallback.businessProfile,
|
|
36
|
+
riskLevel: fallback.riskLevel,
|
|
37
|
+
checks: {
|
|
38
|
+
schema: failure,
|
|
39
|
+
sourceDocs: failureFor("schema-invalid", "Skipped because schema validation failed"),
|
|
40
|
+
acceptanceRefs: failureFor("schema-invalid", "Skipped because schema validation failed"),
|
|
41
|
+
dependsOn: "skipped",
|
|
42
|
+
pathBoundary: failureFor("schema-invalid", "Skipped because schema validation failed"),
|
|
43
|
+
verifyCommands: failureFor("schema-invalid", "Skipped because schema validation failed"),
|
|
44
|
+
profileMapping: failureFor("schema-invalid", "Skipped because schema validation failed"),
|
|
45
|
+
},
|
|
46
|
+
errors,
|
|
47
|
+
warnings: [],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const taskSpec = schemaResult.data;
|
|
51
|
+
const errors = [];
|
|
52
|
+
const warnings = [];
|
|
53
|
+
const sourceDocsCheck = await checkSourceDocs(taskSpec, options, errors);
|
|
54
|
+
const acceptanceRefsCheck = await checkAcceptanceRefs(taskSpec, options, errors);
|
|
55
|
+
const dependsOnCheck = await checkDependsOn(taskSpec, options, errors);
|
|
56
|
+
const pathBoundaryCheck = checkPathBoundaries(taskSpec, errors, warnings);
|
|
57
|
+
const verifyCommandsCheck = checkVerifyCommands(taskSpec, errors);
|
|
58
|
+
const profileMapping = resolveLoopAgentProfile(taskSpec);
|
|
59
|
+
return buildResult({
|
|
60
|
+
taskId: taskSpec.id,
|
|
61
|
+
featureId: taskSpec.feature_id,
|
|
62
|
+
businessProfile: taskSpec.type,
|
|
63
|
+
riskLevel: taskSpec.risk_level,
|
|
64
|
+
loopAgentProfilePreview: profileMapping.loopAgentProfile,
|
|
65
|
+
checks: {
|
|
66
|
+
schema: "ok",
|
|
67
|
+
sourceDocs: sourceDocsCheck,
|
|
68
|
+
acceptanceRefs: acceptanceRefsCheck,
|
|
69
|
+
dependsOn: dependsOnCheck,
|
|
70
|
+
pathBoundary: pathBoundaryCheck,
|
|
71
|
+
verifyCommands: verifyCommandsCheck,
|
|
72
|
+
profileMapping: "ok",
|
|
73
|
+
},
|
|
74
|
+
errors,
|
|
75
|
+
warnings,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function buildResult(input) {
|
|
79
|
+
const riskLevel = input.riskLevel === "low" ||
|
|
80
|
+
input.riskLevel === "medium" ||
|
|
81
|
+
input.riskLevel === "high"
|
|
82
|
+
? input.riskLevel
|
|
83
|
+
: "medium";
|
|
84
|
+
return {
|
|
85
|
+
schemaVersion: 1,
|
|
86
|
+
ok: input.errors.length === 0 && checksAreOk(input.checks),
|
|
87
|
+
taskId: input.taskId,
|
|
88
|
+
featureId: input.featureId,
|
|
89
|
+
businessProfile: input.businessProfile,
|
|
90
|
+
riskLevel: input.riskLevel,
|
|
91
|
+
complexityPreview: mapRiskLevelToComplexity(riskLevel),
|
|
92
|
+
loopAgentProfilePreview: input.loopAgentProfilePreview,
|
|
93
|
+
checks: input.checks,
|
|
94
|
+
errors: input.errors,
|
|
95
|
+
warnings: input.warnings,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function checksAreOk(checks) {
|
|
99
|
+
return Object.values(checks).every((check) => check === "ok" || check === "skipped");
|
|
100
|
+
}
|
|
101
|
+
async function checkSourceDocs(taskSpec, options, errors) {
|
|
102
|
+
const baseDir = getTaskSpecDir(options);
|
|
103
|
+
if (!baseDir)
|
|
104
|
+
return "ok";
|
|
105
|
+
for (const [key, relativePath] of Object.entries(taskSpec.source_docs)) {
|
|
106
|
+
if (!relativePath)
|
|
107
|
+
continue;
|
|
108
|
+
if (!(await exists(path.resolve(baseDir, relativePath)))) {
|
|
109
|
+
const error = {
|
|
110
|
+
layer: "file-existence",
|
|
111
|
+
code: "source-doc-missing",
|
|
112
|
+
message: `source_docs.${key} does not exist: ${relativePath}`,
|
|
113
|
+
path: `source_docs.${key}`,
|
|
114
|
+
};
|
|
115
|
+
errors.push(error);
|
|
116
|
+
return toFailure(error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return "ok";
|
|
120
|
+
}
|
|
121
|
+
async function checkAcceptanceRefs(taskSpec, options, errors) {
|
|
122
|
+
const baseDir = getTaskSpecDir(options);
|
|
123
|
+
if (!baseDir)
|
|
124
|
+
return "ok";
|
|
125
|
+
const acceptancePath = path.resolve(baseDir, taskSpec.source_docs.acceptance);
|
|
126
|
+
if (!(await exists(acceptancePath))) {
|
|
127
|
+
const error = {
|
|
128
|
+
layer: "file-existence",
|
|
129
|
+
code: "acceptance-source-missing",
|
|
130
|
+
message: `acceptance source does not exist: ${taskSpec.source_docs.acceptance}`,
|
|
131
|
+
path: "source_docs.acceptance",
|
|
132
|
+
};
|
|
133
|
+
errors.push(error);
|
|
134
|
+
return toFailure(error);
|
|
135
|
+
}
|
|
136
|
+
const parsed = YAML.parse(await readFile(acceptancePath, "utf-8"));
|
|
137
|
+
const ids = collectStringIds(parsed);
|
|
138
|
+
for (const acceptanceRef of taskSpec.acceptance_refs) {
|
|
139
|
+
if (!ids.has(acceptanceRef)) {
|
|
140
|
+
const error = {
|
|
141
|
+
layer: "file-existence",
|
|
142
|
+
code: "acceptance-ref-missing",
|
|
143
|
+
message: `acceptance ref not found in acceptance source: ${acceptanceRef}`,
|
|
144
|
+
path: "acceptance_refs",
|
|
145
|
+
};
|
|
146
|
+
errors.push(error);
|
|
147
|
+
return toFailure(error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return "ok";
|
|
151
|
+
}
|
|
152
|
+
async function checkDependsOn(taskSpec, options, errors) {
|
|
153
|
+
if (taskSpec.depends_on.length === 0)
|
|
154
|
+
return "skipped";
|
|
155
|
+
const baseDir = getTaskSpecDir(options);
|
|
156
|
+
if (!baseDir)
|
|
157
|
+
return "ok";
|
|
158
|
+
for (const dependency of taskSpec.depends_on) {
|
|
159
|
+
const dependencyPath = path.resolve(baseDir, `${dependency}.yaml`);
|
|
160
|
+
if (!(await exists(dependencyPath))) {
|
|
161
|
+
const error = {
|
|
162
|
+
layer: "file-existence",
|
|
163
|
+
code: "depends-on-missing",
|
|
164
|
+
message: `depends_on task file does not exist: ${dependency}.yaml`,
|
|
165
|
+
path: "depends_on",
|
|
166
|
+
};
|
|
167
|
+
errors.push(error);
|
|
168
|
+
return toFailure(error);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return "ok";
|
|
172
|
+
}
|
|
173
|
+
function checkPathBoundaries(taskSpec, errors, warnings) {
|
|
174
|
+
const start = errors.length;
|
|
175
|
+
for (const [index, allowedPath] of taskSpec.constraints.allowed_paths.entries()) {
|
|
176
|
+
if (isBroadAllowedPath(allowedPath)) {
|
|
177
|
+
errors.push({
|
|
178
|
+
layer: "path-boundary",
|
|
179
|
+
code: "broad-allowed-paths",
|
|
180
|
+
message: `allowed_paths entry is too broad: ${allowedPath}`,
|
|
181
|
+
path: `constraints.allowed_paths[${index}]`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
for (const [forbiddenIndex, forbiddenPath] of taskSpec.constraints.forbidden_paths.entries()) {
|
|
185
|
+
if (patternsOverlap(allowedPath, forbiddenPath)) {
|
|
186
|
+
errors.push({
|
|
187
|
+
layer: "path-boundary",
|
|
188
|
+
code: "allowed-forbidden-overlap",
|
|
189
|
+
message: `allowed path overlaps forbidden path: ${allowedPath} / ${forbiddenPath}`,
|
|
190
|
+
path: `constraints.forbidden_paths[${forbiddenIndex}]`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (CODE_WRITING_TYPES.has(taskSpec.type) &&
|
|
196
|
+
taskSpec.constraints.allowed_paths.length === 0) {
|
|
197
|
+
errors.push({
|
|
198
|
+
layer: "path-boundary",
|
|
199
|
+
code: "code-task-empty-allowed-paths",
|
|
200
|
+
message: `${taskSpec.type} requires non-empty allowed_paths`,
|
|
201
|
+
path: "constraints.allowed_paths",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (taskSpec.constraints.forbidden_paths.length === 0) {
|
|
205
|
+
errors.push({
|
|
206
|
+
layer: "path-boundary",
|
|
207
|
+
code: "forbidden-paths-empty",
|
|
208
|
+
message: "TaskSpec requires non-empty forbidden_paths",
|
|
209
|
+
path: "constraints.forbidden_paths",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (QA_TYPES.has(taskSpec.type) && !hasQaBusinessCodeForbiddenPath(taskSpec)) {
|
|
213
|
+
errors.push({
|
|
214
|
+
layer: "path-boundary",
|
|
215
|
+
code: "qa-business-code-not-forbidden",
|
|
216
|
+
message: "QA tasks must forbid common business code directories",
|
|
217
|
+
path: "constraints.forbidden_paths",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const complexity = mapRiskLevelToComplexity(taskSpec.risk_level);
|
|
221
|
+
if ((complexity === "medium" || complexity === "large") &&
|
|
222
|
+
taskSpec.constraints.allowed_paths.length === 0) {
|
|
223
|
+
errors.push({
|
|
224
|
+
layer: "path-boundary",
|
|
225
|
+
code: "empty-allowed-paths-for-complexity",
|
|
226
|
+
message: `${complexity} tasks require non-empty allowed_paths before materialize`,
|
|
227
|
+
path: "constraints.allowed_paths",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (complexity === "small" && taskSpec.constraints.allowed_paths.length === 0) {
|
|
231
|
+
warnings.push({
|
|
232
|
+
code: "empty-allowed-paths-path-guard-risk",
|
|
233
|
+
message: "empty allowed_paths will make non-harness writes fail path guard",
|
|
234
|
+
path: "constraints.allowed_paths",
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const pathErrors = errors.slice(start).filter((error) => error.layer === "path-boundary");
|
|
238
|
+
if (pathErrors.length === 0)
|
|
239
|
+
return "ok";
|
|
240
|
+
return toFailure(pathErrors[0]);
|
|
241
|
+
}
|
|
242
|
+
function checkVerifyCommands(taskSpec, errors) {
|
|
243
|
+
if (!VERIFY_OPTIONAL_TYPES.has(taskSpec.type) &&
|
|
244
|
+
taskSpec.verify.commands.length === 0) {
|
|
245
|
+
const error = {
|
|
246
|
+
layer: "file-existence",
|
|
247
|
+
code: "verify-commands-empty",
|
|
248
|
+
message: `${taskSpec.type} requires verify.commands`,
|
|
249
|
+
path: "verify.commands",
|
|
250
|
+
};
|
|
251
|
+
errors.push(error);
|
|
252
|
+
return toFailure(error);
|
|
253
|
+
}
|
|
254
|
+
return "ok";
|
|
255
|
+
}
|
|
256
|
+
function getTaskSpecDir(options) {
|
|
257
|
+
return options.taskSpecPath ? path.dirname(options.taskSpecPath) : undefined;
|
|
258
|
+
}
|
|
259
|
+
async function exists(filePath) {
|
|
260
|
+
try {
|
|
261
|
+
await access(filePath);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function collectStringIds(value, ids = new Set()) {
|
|
269
|
+
if (Array.isArray(value)) {
|
|
270
|
+
for (const item of value)
|
|
271
|
+
collectStringIds(item, ids);
|
|
272
|
+
return ids;
|
|
273
|
+
}
|
|
274
|
+
if (value && typeof value === "object") {
|
|
275
|
+
for (const [key, child] of Object.entries(value)) {
|
|
276
|
+
if (key === "id" && typeof child === "string")
|
|
277
|
+
ids.add(child);
|
|
278
|
+
collectStringIds(child, ids);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return ids;
|
|
282
|
+
}
|
|
283
|
+
function isBroadAllowedPath(pattern) {
|
|
284
|
+
const normalized = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
285
|
+
return normalized === "**" || normalized === "./**" || normalized === ".";
|
|
286
|
+
}
|
|
287
|
+
function probeForPattern(pattern) {
|
|
288
|
+
return pattern
|
|
289
|
+
.replace(/\\/g, "/")
|
|
290
|
+
.replace(/\/\*\*$/, "/_probe_")
|
|
291
|
+
.replace(/\*\*$/, "_probe_")
|
|
292
|
+
.replace(/\*/g, "_")
|
|
293
|
+
.replace(/^\.\//, "");
|
|
294
|
+
}
|
|
295
|
+
function patternsOverlap(left, right) {
|
|
296
|
+
const leftProbe = probeForPattern(left);
|
|
297
|
+
const rightProbe = probeForPattern(right);
|
|
298
|
+
return (pathMatchesPattern(leftProbe, right) ||
|
|
299
|
+
pathMatchesPattern(rightProbe, left) ||
|
|
300
|
+
leftProbe === rightProbe);
|
|
301
|
+
}
|
|
302
|
+
function pathMatchesPattern(filePath, pattern) {
|
|
303
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
304
|
+
const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
305
|
+
if (normalizedPattern.endsWith("/**")) {
|
|
306
|
+
const prefix = normalizedPattern.slice(0, -3);
|
|
307
|
+
return normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`);
|
|
308
|
+
}
|
|
309
|
+
if (normalizedPattern.includes("*")) {
|
|
310
|
+
const escaped = normalizedPattern
|
|
311
|
+
.split("*")
|
|
312
|
+
.map((part) => part.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"))
|
|
313
|
+
.join(".*");
|
|
314
|
+
return new RegExp(`^${escaped}$`).test(normalizedPath);
|
|
315
|
+
}
|
|
316
|
+
return normalizedPath === normalizedPattern;
|
|
317
|
+
}
|
|
318
|
+
function hasQaBusinessCodeForbiddenPath(taskSpec) {
|
|
319
|
+
return taskSpec.constraints.forbidden_paths.some((forbiddenPath) => {
|
|
320
|
+
const normalized = forbiddenPath.replace(/\\/g, "/");
|
|
321
|
+
return (normalized.includes("/src/") ||
|
|
322
|
+
normalized.includes("src/**") ||
|
|
323
|
+
normalized.includes("/services/") ||
|
|
324
|
+
normalized.includes("services/**") ||
|
|
325
|
+
normalized.includes("/app/") ||
|
|
326
|
+
normalized.includes("app/**") ||
|
|
327
|
+
normalized.includes("/apps/"));
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
function fallbackIdentity(input) {
|
|
331
|
+
const record = input && typeof input === "object" ? input : {};
|
|
332
|
+
return {
|
|
333
|
+
taskId: typeof record.id === "string" ? record.id : "",
|
|
334
|
+
featureId: typeof record.feature_id === "string" ? record.feature_id : "",
|
|
335
|
+
businessProfile: typeof record.type === "string" ? record.type : "",
|
|
336
|
+
riskLevel: typeof record.risk_level === "string" ? record.risk_level : "medium",
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function failureFor(code, message, pathValue) {
|
|
340
|
+
return { status: "failed", code, message, path: pathValue };
|
|
341
|
+
}
|
|
342
|
+
function firstFailure(errors, code, message) {
|
|
343
|
+
return errors[0] ? toFailure(errors[0]) : failureFor(code, message);
|
|
344
|
+
}
|
|
345
|
+
function toFailure(error) {
|
|
346
|
+
return {
|
|
347
|
+
status: "failed",
|
|
348
|
+
code: error.code,
|
|
349
|
+
message: error.message,
|
|
350
|
+
path: error.path,
|
|
351
|
+
};
|
|
352
|
+
}
|