agent-project-sdlc 0.1.9 → 0.1.11
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 -1
- package/assets/agents/AGENTS_CORE.md +20 -16
- package/assets/docs/README.md +20 -5
- package/assets/make/sdlc-harness.mk +6 -1
- package/assets/policies/allowed_paths.yaml +3 -0
- package/assets/policies/gates.yaml +12 -0
- package/assets/policies/phase_contracts.yaml +12 -0
- package/assets/skills/pjsdlc_architect_design/SKILL.md +32 -3
- package/assets/skills/pjsdlc_dev_sprint/SKILL.md +6 -6
- package/assets/skills/pjsdlc_manager/SKILL.md +9 -7
- package/assets/skills/pjsdlc_pm_prd/SKILL.md +26 -2
- package/assets/skills/pjsdlc_release_manager/SKILL.md +18 -0
- package/assets/skills/pjsdlc_reviewer/SKILL.md +18 -0
- package/assets/skills/pjsdlc_rfc_recalibrate/SKILL.md +17 -0
- package/assets/skills/pjsdlc_tester/SKILL.md +18 -0
- package/assets/templates/PLAN_TEMPLATE.yaml +16 -13
- package/assets/templates/TECH_DESIGN_TEMPLATE.md +1 -1
- package/dist/commands/index.js +6 -0
- package/dist/lib/init.js +2 -2
- package/dist/lib/migrations.js +30 -11
- package/dist/lib/validators.js +368 -25
- package/package.json +1 -1
package/dist/lib/validators.js
CHANGED
|
@@ -6,13 +6,38 @@ import { listFiles, pathExists, readText } from "./fs.js";
|
|
|
6
6
|
import { parseYaml } from "./yaml.js";
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
8
|
const PARALLEL_MODES = new Set(["runtime_managed", "user_orchestrated"]);
|
|
9
|
-
const
|
|
9
|
+
const TASK_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
10
|
+
const PARALLEL_ALLOWED_PHASES = new Set(["REQUIREMENT_GATHERING", "SPRINTING", "TESTING"]);
|
|
11
|
+
const TASK_STATUSES = new Set(["pending", "in_progress", "done", "blocked", "pending_revision", "cancelled"]);
|
|
12
|
+
const OPEN_TASK_STATUSES = new Set(["pending", "in_progress", "blocked", "pending_revision"]);
|
|
13
|
+
const DESIGN_CATEGORIES = [
|
|
14
|
+
{
|
|
15
|
+
label: "AI copilot/provider",
|
|
16
|
+
triggerTerms: ["ai provider", "ai output", "aioutput", "llm", "copilot", "副驾驶"],
|
|
17
|
+
architectureTerms: ["ai provider", "ai output", "llm", "copilot", "副驾驶", "模型", "智能", "prompt"]
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
label: "external system boundary",
|
|
21
|
+
triggerTerms: ["external system", "external integration", "webhook", "外部系统", "第三方", "微信", "工商", "税务", "社保", "公积金", "金蝶", "对象存储"],
|
|
22
|
+
architectureTerms: ["external system", "external integration", "webhook", "adapter", "适配", "边界", "外部系统", "第三方", "微信", "工商", "税务", "社保", "公积金", "金蝶", "对象存储"]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
label: "compliance/permission/audit",
|
|
26
|
+
triggerTerms: ["compliance", "authorization", "audit log", "audit trail", "合规", "授权", "客户确认", "回执归档", "权限模型", "权限控制", "权限架构", "审计架构", "审计日志"],
|
|
27
|
+
architectureTerms: ["compliance", "permission", "authorization", "audit", "合规", "权限", "审计", "授权", "客户确认", "回执归档"]
|
|
28
|
+
}
|
|
29
|
+
];
|
|
10
30
|
const validators = {
|
|
11
31
|
"validate-harness": validateHarness,
|
|
12
32
|
"validate-current": validateCurrent,
|
|
33
|
+
"validate-plan": validatePlan,
|
|
13
34
|
"validate-pm": validatePm,
|
|
14
35
|
"validate-design": validateDesign,
|
|
15
|
-
"validate-dev": validateDev
|
|
36
|
+
"validate-dev": validateDev,
|
|
37
|
+
"validate-review": validateReview,
|
|
38
|
+
"validate-test": validateTest,
|
|
39
|
+
"validate-release": validateRelease,
|
|
40
|
+
"validate-rfc": validateRfc
|
|
16
41
|
};
|
|
17
42
|
export async function runValidator(projectRoot, gate) {
|
|
18
43
|
const normalized = normalizeGate(gate);
|
|
@@ -51,14 +76,19 @@ async function validateCurrent(projectRoot) {
|
|
|
51
76
|
const gateByPhase = {
|
|
52
77
|
REQUIREMENT_GATHERING: "validate-pm",
|
|
53
78
|
ARCHITECTING: "validate-design",
|
|
54
|
-
SPRINTING: "validate-dev"
|
|
79
|
+
SPRINTING: "validate-dev",
|
|
80
|
+
REVIEWING: "validate-review",
|
|
81
|
+
TESTING: "validate-test",
|
|
82
|
+
RELEASING: "validate-release",
|
|
83
|
+
RFC_RECALIBRATION: "validate-rfc"
|
|
55
84
|
};
|
|
56
85
|
return runValidator(projectRoot, gateByPhase[current] ?? "validate-harness");
|
|
57
86
|
}
|
|
58
87
|
async function validatePm(projectRoot) {
|
|
88
|
+
const plan = await validatePlanState(projectRoot, false);
|
|
59
89
|
const docs = await markdownFiles(path.join(projectRoot, ".docs/01_product"));
|
|
60
90
|
const text = await combinedText(docs);
|
|
61
|
-
const errors = [];
|
|
91
|
+
const errors = [...plan.errors];
|
|
62
92
|
if (docs.length === 0)
|
|
63
93
|
errors.push("No PRD deliverables found");
|
|
64
94
|
if (!containsAny(text, ["acceptance", "验收"]))
|
|
@@ -70,10 +100,14 @@ async function validatePm(projectRoot) {
|
|
|
70
100
|
return { info: [`validate-pm checked ${docs.length} file(s)`], errors };
|
|
71
101
|
}
|
|
72
102
|
async function validateDesign(projectRoot) {
|
|
103
|
+
const root = await harnessRoot(projectRoot);
|
|
104
|
+
const lifecycle = await readYamlObject(path.join(projectRoot, root, "state", "lifecycle.yaml"));
|
|
105
|
+
const plan = await validatePlanState(projectRoot, String(lifecycle.current_phase ?? "") !== "ARCHITECTING");
|
|
73
106
|
const architecture = await markdownFiles(path.join(projectRoot, ".docs/02_architecture"));
|
|
74
107
|
const techPlan = await markdownFiles(path.join(projectRoot, ".docs/03_tech_plan"));
|
|
108
|
+
const product = await markdownFiles(path.join(projectRoot, ".docs/01_product"));
|
|
75
109
|
const text = await combinedText([...architecture, ...techPlan]);
|
|
76
|
-
const errors = [];
|
|
110
|
+
const errors = [...plan.errors];
|
|
77
111
|
if (architecture.length === 0)
|
|
78
112
|
errors.push("No architecture deliverables found");
|
|
79
113
|
if (techPlan.length === 0)
|
|
@@ -84,29 +118,264 @@ async function validateDesign(projectRoot) {
|
|
|
84
118
|
errors.push("Design must describe interfaces or contracts");
|
|
85
119
|
if (!containsAny(text, ["task", "任务", "breakdown"]))
|
|
86
120
|
errors.push("Design must include task breakdown");
|
|
121
|
+
const draft = await validateDesignDraft(projectRoot, root, techPlan);
|
|
122
|
+
errors.push(...draft.errors);
|
|
123
|
+
errors.push(...(await validateCrossCuttingArchitecture(projectRoot, product, techPlan, architecture, draft.tasks)));
|
|
87
124
|
return { info: [`validate-design checked ${architecture.length + techPlan.length} file(s)`], errors };
|
|
88
125
|
}
|
|
126
|
+
async function validatePlan(projectRoot) {
|
|
127
|
+
const plan = await validatePlanState(projectRoot, true);
|
|
128
|
+
const pathErrors = await validateChangedPaths(projectRoot, plan.plan, true);
|
|
129
|
+
return { info: [`validate-plan checked ${plan.taskCount} task(s)`], errors: [...plan.errors, ...pathErrors] };
|
|
130
|
+
}
|
|
131
|
+
async function validateDesignDraft(projectRoot, root, techPlanFiles) {
|
|
132
|
+
const errors = [];
|
|
133
|
+
const draft = await readYamlObject(path.join(projectRoot, root, "state", "plan.draft.yaml"));
|
|
134
|
+
if ("current_phase" in draft) {
|
|
135
|
+
errors.push("plan.draft.yaml must not define current_phase; lifecycle.yaml is the single source for current_phase");
|
|
136
|
+
}
|
|
137
|
+
if ("current_task_id" in draft) {
|
|
138
|
+
errors.push("plan.draft.yaml must not define current_task_id because drafts are not active task state");
|
|
139
|
+
}
|
|
140
|
+
const rawTasks = draft.tasks;
|
|
141
|
+
if (!Array.isArray(rawTasks) || rawTasks.length === 0) {
|
|
142
|
+
errors.push("plan.draft.yaml must contain at least one task before leaving ARCHITECTING");
|
|
143
|
+
return { errors, tasks: [] };
|
|
144
|
+
}
|
|
145
|
+
const tasks = rawTasks.filter(isRecord);
|
|
146
|
+
const availableTechPlans = new Set(techPlanFiles.map((file) => repoRelative(projectRoot, file)));
|
|
147
|
+
const developmentTasks = [];
|
|
148
|
+
const primaryRefs = [];
|
|
149
|
+
rawTasks.forEach((rawTask, index) => {
|
|
150
|
+
if (!isRecord(rawTask)) {
|
|
151
|
+
errors.push(`Task draft #${index + 1} must be a mapping`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
validateDraftTaskShape(rawTask, index, errors);
|
|
155
|
+
if (rawTask.status !== "pending") {
|
|
156
|
+
errors.push(`Draft task ${String(rawTask.id ?? "")} should start as pending`);
|
|
157
|
+
}
|
|
158
|
+
if (!isDevelopmentDraft(rawTask))
|
|
159
|
+
return;
|
|
160
|
+
developmentTasks.push(rawTask);
|
|
161
|
+
if (!isRecord(rawTask.docs)) {
|
|
162
|
+
errors.push(`Draft task ${String(rawTask.id ?? "")} docs must be a mapping`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const techRefs = asStringList(rawTask.docs.tech_plan);
|
|
166
|
+
if (techRefs.length === 0) {
|
|
167
|
+
errors.push(`Draft task ${String(rawTask.id ?? "")} must reference at least one tech plan slice in docs.tech_plan`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const normalizedRefs = techRefs.map(normalizeDocRef);
|
|
171
|
+
for (const ref of normalizedRefs) {
|
|
172
|
+
if (!ref.startsWith(".docs/03_tech_plan/")) {
|
|
173
|
+
errors.push(`Draft task ${String(rawTask.id ?? "")} docs.tech_plan must point into .docs/03_tech_plan/: ${ref}`);
|
|
174
|
+
}
|
|
175
|
+
else if (!availableTechPlans.has(ref)) {
|
|
176
|
+
errors.push(`Draft task ${String(rawTask.id ?? "")} references missing or generated tech plan slice: ${ref}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
primaryRefs.push(normalizedRefs[0]);
|
|
180
|
+
});
|
|
181
|
+
if (developmentTasks.length === 0) {
|
|
182
|
+
errors.push("plan.draft.yaml must contain at least one development task with implementation_doc");
|
|
183
|
+
}
|
|
184
|
+
if (developmentTasks.length > 1 && new Set(primaryRefs).size !== primaryRefs.length) {
|
|
185
|
+
errors.push("Draft development tasks must reference distinct primary tech plan slices in docs.tech_plan");
|
|
186
|
+
}
|
|
187
|
+
return { errors, tasks };
|
|
188
|
+
}
|
|
189
|
+
function validateDraftTaskShape(task, index, errors) {
|
|
190
|
+
const prefix = `Task #${index + 1}`;
|
|
191
|
+
for (const field of ["id", "title", "status", "summary"]) {
|
|
192
|
+
if (!task[field])
|
|
193
|
+
errors.push(`${prefix} missing field: ${field}`);
|
|
194
|
+
}
|
|
195
|
+
const taskId = String(task.id ?? "");
|
|
196
|
+
if (!/^[A-Z]+-\d+$/.test(taskId)) {
|
|
197
|
+
errors.push(`${taskId || prefix} id must match PREFIX-###`);
|
|
198
|
+
}
|
|
199
|
+
if (taskId.startsWith("TASK-") && !TASK_PHASES.has(String(task.phase ?? ""))) {
|
|
200
|
+
errors.push(`${taskId} must define valid phase`);
|
|
201
|
+
}
|
|
202
|
+
else if (task.phase !== undefined && !TASK_PHASES.has(String(task.phase))) {
|
|
203
|
+
errors.push(`${taskId} has invalid phase: ${String(task.phase)}`);
|
|
204
|
+
}
|
|
205
|
+
if (!TASK_STATUSES.has(String(task.status))) {
|
|
206
|
+
errors.push(`${String(task.id ?? prefix)} has invalid status: ${String(task.status)}`);
|
|
207
|
+
}
|
|
208
|
+
if (typeof task.summary !== "string" || !task.summary.trim()) {
|
|
209
|
+
errors.push(`${String(task.id ?? prefix)} must define summary`);
|
|
210
|
+
}
|
|
211
|
+
const hasImplementationDoc = typeof task.implementation_doc === "string" && task.implementation_doc.trim().length > 0;
|
|
212
|
+
const hasResultDocs = Array.isArray(task.result_docs) && task.result_docs.length > 0;
|
|
213
|
+
if (!hasImplementationDoc && !hasResultDocs) {
|
|
214
|
+
errors.push(`${String(task.id ?? prefix)} must define implementation_doc or result_docs`);
|
|
215
|
+
}
|
|
216
|
+
if (OPEN_TASK_STATUSES.has(String(task.status))) {
|
|
217
|
+
if ("gate_result" in task)
|
|
218
|
+
errors.push(`${String(task.id ?? prefix)} open task must not define gate_result`);
|
|
219
|
+
for (const field of ["docs", "allowed_paths", "required_gates", "acceptance_criteria"]) {
|
|
220
|
+
if (!task[field])
|
|
221
|
+
errors.push(`${String(task.id ?? prefix)} open task missing field: ${field}`);
|
|
222
|
+
}
|
|
223
|
+
if (!isRecord(task.docs))
|
|
224
|
+
errors.push(`${String(task.id ?? prefix)} docs must be a mapping`);
|
|
225
|
+
if (!Array.isArray(task.allowed_paths) || task.allowed_paths.length === 0) {
|
|
226
|
+
errors.push(`${String(task.id ?? prefix)} must define allowed_paths`);
|
|
227
|
+
}
|
|
228
|
+
if (!Array.isArray(task.required_gates) || task.required_gates.length === 0) {
|
|
229
|
+
errors.push(`${String(task.id ?? prefix)} must define required_gates`);
|
|
230
|
+
}
|
|
231
|
+
if (!Array.isArray(task.acceptance_criteria) || task.acceptance_criteria.length === 0) {
|
|
232
|
+
errors.push(`${String(task.id ?? prefix)} must define acceptance_criteria`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
for (const field of ["docs", "allowed_paths", "required_gates", "acceptance_criteria", "working_notes", "gate_result", "result_docs"]) {
|
|
237
|
+
if (field in task)
|
|
238
|
+
errors.push(`${String(task.id ?? prefix)} closed task must not retain ${field}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function validateCrossCuttingArchitecture(projectRoot, productFiles, techPlanFiles, architectureFiles, draftTasks) {
|
|
243
|
+
const errors = [];
|
|
244
|
+
const sourceText = [
|
|
245
|
+
await combinedText(productFiles),
|
|
246
|
+
await combinedText(techPlanFiles),
|
|
247
|
+
draftTasks.map(taskText).join("\n")
|
|
248
|
+
].join("\n");
|
|
249
|
+
const architectureTexts = await Promise.all(architectureFiles.map(async (file) => ({ file, text: await readText(file) })));
|
|
250
|
+
const assigned = new Set();
|
|
251
|
+
for (const category of DESIGN_CATEGORIES) {
|
|
252
|
+
if (!containsAny(sourceText, category.triggerTerms))
|
|
253
|
+
continue;
|
|
254
|
+
const match = architectureTexts.find((doc) => !assigned.has(repoRelative(projectRoot, doc.file)) && containsAny(doc.text, category.architectureTerms));
|
|
255
|
+
if (!match) {
|
|
256
|
+
errors.push(`Design requires a dedicated ${category.label} architecture slice`);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
assigned.add(repoRelative(projectRoot, match.file));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return errors;
|
|
263
|
+
}
|
|
89
264
|
async function validateDev(projectRoot) {
|
|
265
|
+
const plan = await validatePlanState(projectRoot, false);
|
|
266
|
+
return { info: [`validate-dev checked ${plan.taskCount} task(s)`], errors: plan.errors };
|
|
267
|
+
}
|
|
268
|
+
async function validateReview(projectRoot) {
|
|
269
|
+
const plan = await validatePlanState(projectRoot, false);
|
|
270
|
+
const text = (await readText(path.join(projectRoot, ".docs/06_review/REVIEW_REPORT.md"))).toLowerCase();
|
|
271
|
+
const errors = [...plan.errors];
|
|
272
|
+
if (!containsAny(text, ["finding", "发现", "风险"]))
|
|
273
|
+
errors.push("Review report must include findings or risks");
|
|
274
|
+
if (!containsAny(text, ["test gap", "测试缺口", "coverage"]))
|
|
275
|
+
errors.push("Review report must include test gaps or coverage notes");
|
|
276
|
+
if (!containsAny(text, ["pass", "blocked", "通过", "阻塞"]))
|
|
277
|
+
errors.push("Review report must include PASS/BLOCKED decision");
|
|
278
|
+
return { info: ["validate-review checked review report"], errors };
|
|
279
|
+
}
|
|
280
|
+
async function validateTest(projectRoot) {
|
|
281
|
+
const plan = await validatePlanState(projectRoot, false);
|
|
282
|
+
const text = (await readText(path.join(projectRoot, ".docs/07_test/TEST_PLAN.md"))).toLowerCase();
|
|
283
|
+
const errors = [...plan.errors];
|
|
284
|
+
if (!containsAny(text, ["matrix", "矩阵"]))
|
|
285
|
+
errors.push("Test plan must include a test matrix");
|
|
286
|
+
if (!containsAny(text, ["regression", "回归"]))
|
|
287
|
+
errors.push("Test plan must include regression coverage");
|
|
288
|
+
if (!containsAny(text, ["coverage gap", "覆盖缺口", "gap"]))
|
|
289
|
+
errors.push("Test plan must include coverage gaps");
|
|
290
|
+
if (!containsAny(text, ["pass", "blocked", "通过", "阻塞"]))
|
|
291
|
+
errors.push("Test plan must include PASS/BLOCKED decision");
|
|
292
|
+
return { info: ["validate-test checked test plan"], errors };
|
|
293
|
+
}
|
|
294
|
+
async function validateRelease(projectRoot) {
|
|
295
|
+
const plan = await validatePlanState(projectRoot, false);
|
|
296
|
+
const docs = await markdownFiles(path.join(projectRoot, ".docs/08_release"));
|
|
297
|
+
const text = await combinedText(docs);
|
|
298
|
+
const errors = [...plan.errors];
|
|
299
|
+
if (docs.length === 0)
|
|
300
|
+
errors.push("No release deliverables found");
|
|
301
|
+
if (!containsAny(text, ["release", "发布"]))
|
|
302
|
+
errors.push("Release docs must include release notes");
|
|
303
|
+
if (!containsAny(text, ["smoke", "冒烟"]))
|
|
304
|
+
errors.push("Release docs must include smoke test evidence");
|
|
305
|
+
if (!containsAny(text, ["rollback", "回滚"]))
|
|
306
|
+
errors.push("Release docs must include rollback plan");
|
|
307
|
+
return { info: [`validate-release checked ${docs.length} file(s)`], errors };
|
|
308
|
+
}
|
|
309
|
+
async function validateRfc(projectRoot) {
|
|
310
|
+
const plan = await validatePlanState(projectRoot, false);
|
|
311
|
+
const docs = await markdownFiles(path.join(projectRoot, ".docs/rfc"));
|
|
312
|
+
const text = await combinedText(docs);
|
|
313
|
+
const errors = [...plan.errors];
|
|
314
|
+
if (docs.length === 0)
|
|
315
|
+
errors.push("No RFC documents found");
|
|
316
|
+
if (!containsAny(text, ["background", "背景"]))
|
|
317
|
+
errors.push("RFC must include background");
|
|
318
|
+
if (!containsAny(text, ["product impact", "产品影响"]))
|
|
319
|
+
errors.push("RFC must include product impact");
|
|
320
|
+
if (!containsAny(text, ["technical impact", "技术影响"]))
|
|
321
|
+
errors.push("RFC must include technical impact candidates");
|
|
322
|
+
if (!containsAny(text, ["regression", "回归"]))
|
|
323
|
+
errors.push("RFC must include regression requirements");
|
|
324
|
+
const statuses = [...text.matchAll(/status:\s*([a-z_]+)/g)].map((match) => match[1].toUpperCase());
|
|
325
|
+
if (statuses.length === 0)
|
|
326
|
+
errors.push("RFC must include a Status line");
|
|
327
|
+
const invalidStatuses = statuses.filter((status) => !["DRAFT", "APPLIED", "VERIFIED", "ARCHIVED"].includes(status));
|
|
328
|
+
if (invalidStatuses.length > 0)
|
|
329
|
+
errors.push(`Invalid RFC status: ${invalidStatuses.join(", ")}`);
|
|
330
|
+
return { info: [`validate-rfc checked ${docs.length} file(s)`], errors };
|
|
331
|
+
}
|
|
332
|
+
async function validatePlanState(projectRoot, allowOpen) {
|
|
90
333
|
const errors = [];
|
|
91
334
|
const root = await harnessRoot(projectRoot);
|
|
92
335
|
const tasksData = await readYamlObject(path.join(projectRoot, root, "state", "plan.yaml"));
|
|
93
|
-
|
|
336
|
+
const lifecycle = await readYamlObject(path.join(projectRoot, root, "state", "lifecycle.yaml"));
|
|
337
|
+
const currentPhase = String(lifecycle.current_phase ?? "");
|
|
338
|
+
if ("current_phase" in tasksData) {
|
|
339
|
+
errors.push("plan.yaml must not define current_phase; lifecycle.yaml is the single source for current_phase");
|
|
340
|
+
}
|
|
341
|
+
validateParallelExecutionContract(tasksData, currentPhase, errors);
|
|
94
342
|
const tasks = Array.isArray(tasksData.tasks) ? tasksData.tasks : [];
|
|
95
343
|
const nextTaskSequence = tasksData.next_task_sequence;
|
|
96
344
|
if (!Number.isInteger(nextTaskSequence) || Number(nextTaskSequence) <= 0) {
|
|
97
345
|
errors.push("plan.yaml must define positive integer next_task_sequence");
|
|
98
346
|
}
|
|
99
347
|
const open = tasks.filter((task) => ["pending", "in_progress", "blocked", "pending_revision"].includes(String(task.status)));
|
|
100
|
-
if (open.length > 0)
|
|
348
|
+
if (!allowOpen && open.length > 0)
|
|
101
349
|
errors.push(`Open tasks remain: ${open.map((task) => task.id).join(", ")}`);
|
|
102
350
|
let maxTaskSequence = 0;
|
|
103
|
-
|
|
104
|
-
|
|
351
|
+
tasks.forEach((task, index) => {
|
|
352
|
+
if (!isRecord(task)) {
|
|
353
|
+
errors.push(`Task #${index + 1} must be a mapping`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
for (const field of ["id", "title", "status", "summary"]) {
|
|
105
357
|
if (!task[field])
|
|
106
358
|
errors.push(`Task missing ${field}: ${String(task.id ?? "unknown")}`);
|
|
107
359
|
}
|
|
108
360
|
const taskId = String(task.id ?? "");
|
|
109
|
-
|
|
361
|
+
if (!/^[A-Z]+-\d+$/.test(taskId)) {
|
|
362
|
+
errors.push(`${taskId || `Task #${index + 1}`} id must match PREFIX-###`);
|
|
363
|
+
}
|
|
364
|
+
if (taskId.startsWith("TASK-") && !TASK_PHASES.has(String(task.phase ?? ""))) {
|
|
365
|
+
errors.push(`${taskId} must define valid phase`);
|
|
366
|
+
}
|
|
367
|
+
else if (task.phase !== undefined && !TASK_PHASES.has(String(task.phase))) {
|
|
368
|
+
errors.push(`${taskId} has invalid phase: ${String(task.phase)}`);
|
|
369
|
+
}
|
|
370
|
+
if (!["pending", "in_progress", "done", "blocked", "pending_revision", "cancelled"].includes(String(task.status))) {
|
|
371
|
+
errors.push(`${String(task.id ?? `Task #${index + 1}`)} has invalid status: ${String(task.status)}`);
|
|
372
|
+
}
|
|
373
|
+
const hasImplementationDoc = typeof task.implementation_doc === "string" && task.implementation_doc.trim().length > 0;
|
|
374
|
+
const hasResultDocs = Array.isArray(task.result_docs) && task.result_docs.length > 0;
|
|
375
|
+
if (!hasImplementationDoc && !hasResultDocs) {
|
|
376
|
+
errors.push(`${String(task.id ?? `Task #${index + 1}`)} must define implementation_doc or result_docs`);
|
|
377
|
+
}
|
|
378
|
+
const match = taskId.match(/^[A-Z]+-(\d+)$/);
|
|
110
379
|
if (match) {
|
|
111
380
|
maxTaskSequence = Math.max(maxTaskSequence, Number(match[1]));
|
|
112
381
|
}
|
|
@@ -118,27 +387,37 @@ async function validateDev(projectRoot) {
|
|
|
118
387
|
if (!task[field])
|
|
119
388
|
errors.push(`Open task ${task.id} missing ${field}`);
|
|
120
389
|
}
|
|
390
|
+
if (!isRecord(task.docs)) {
|
|
391
|
+
errors.push(`${task.id} docs must be a mapping`);
|
|
392
|
+
}
|
|
121
393
|
if (!Array.isArray(task.allowed_paths) || task.allowed_paths.length === 0) {
|
|
122
394
|
errors.push(`Open task ${task.id} must define allowed_paths`);
|
|
123
395
|
}
|
|
124
396
|
if (!Array.isArray(task.required_gates) || task.required_gates.length === 0) {
|
|
125
397
|
errors.push(`Open task ${task.id} must define required_gates`);
|
|
126
398
|
}
|
|
399
|
+
if (!Array.isArray(task.acceptance_criteria) || task.acceptance_criteria.length === 0) {
|
|
400
|
+
errors.push(`Open task ${task.id} must define acceptance_criteria`);
|
|
401
|
+
}
|
|
127
402
|
}
|
|
128
403
|
else {
|
|
129
404
|
errors.push(`Completed task ${task.id} must not remain in plan.yaml`);
|
|
130
|
-
for (const field of ["docs", "allowed_paths", "required_gates", "acceptance_criteria", "working_notes", "gate_result"]) {
|
|
131
|
-
if (task
|
|
405
|
+
for (const field of ["docs", "allowed_paths", "required_gates", "acceptance_criteria", "working_notes", "gate_result", "result_docs"]) {
|
|
406
|
+
if (field in task)
|
|
132
407
|
errors.push(`Closed task ${task.id} must not retain ${field}`);
|
|
133
408
|
}
|
|
134
409
|
}
|
|
135
|
-
}
|
|
410
|
+
});
|
|
136
411
|
if (Number.isInteger(nextTaskSequence) && Number(nextTaskSequence) <= maxTaskSequence) {
|
|
137
412
|
errors.push("next_task_sequence must be greater than task ids currently in plan.yaml");
|
|
138
413
|
}
|
|
139
|
-
|
|
414
|
+
const currentTaskId = String(tasksData.current_task_id ?? "");
|
|
415
|
+
if (currentTaskId && !tasks.some((task) => isRecord(task) && task.id === currentTaskId)) {
|
|
416
|
+
errors.push(`current_task_id does not match a task: ${currentTaskId}`);
|
|
417
|
+
}
|
|
418
|
+
return { taskCount: tasks.length, errors, plan: tasksData };
|
|
140
419
|
}
|
|
141
|
-
function validateParallelExecutionContract(plan, errors) {
|
|
420
|
+
function validateParallelExecutionContract(plan, currentPhase, errors) {
|
|
142
421
|
const contract = plan.parallel_execution;
|
|
143
422
|
if (contract === undefined || contract === null)
|
|
144
423
|
return;
|
|
@@ -153,17 +432,19 @@ function validateParallelExecutionContract(plan, errors) {
|
|
|
153
432
|
if (!PARALLEL_MODES.has(String(contract.mode ?? ""))) {
|
|
154
433
|
errors.push("parallel_execution.mode must be runtime_managed or user_orchestrated");
|
|
155
434
|
}
|
|
156
|
-
if (
|
|
157
|
-
errors.push("parallel_execution
|
|
435
|
+
if ("phase" in contract) {
|
|
436
|
+
errors.push("parallel_execution must not define phase; lifecycle.yaml is the single source for current_phase");
|
|
437
|
+
}
|
|
438
|
+
if ("linked_task_id" in contract) {
|
|
439
|
+
errors.push("parallel_execution must not define linked_task_id; use plan.yaml current_task_id");
|
|
440
|
+
}
|
|
441
|
+
if (!PARALLEL_ALLOWED_PHASES.has(currentPhase)) {
|
|
442
|
+
errors.push("parallel_execution is only supported during REQUIREMENT_GATHERING, SPRINTING, or TESTING");
|
|
158
443
|
}
|
|
159
444
|
if (contract.coordinator !== "main_agent")
|
|
160
445
|
errors.push('parallel_execution.coordinator must be "main_agent"');
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
errors.push("SPRINTING parallel_execution must define linked_task_id");
|
|
164
|
-
if (contract.linked_task_id !== plan.current_task_id) {
|
|
165
|
-
errors.push("SPRINTING parallel_execution.linked_task_id must match current_task_id");
|
|
166
|
-
}
|
|
446
|
+
if (currentPhase === "SPRINTING" && !plan.current_task_id) {
|
|
447
|
+
errors.push("SPRINTING parallel_execution requires plan.yaml current_task_id");
|
|
167
448
|
}
|
|
168
449
|
const workers = contract.workers;
|
|
169
450
|
if (!Array.isArray(workers) || workers.length === 0) {
|
|
@@ -227,6 +508,36 @@ function validateParallelExecutionContract(plan, errors) {
|
|
|
227
508
|
errors.push("parallel_execution.integration.fact_source_updates must be a non-empty list");
|
|
228
509
|
}
|
|
229
510
|
}
|
|
511
|
+
async function validateChangedPaths(projectRoot, plan, allowOpen) {
|
|
512
|
+
if (!allowOpen)
|
|
513
|
+
return [];
|
|
514
|
+
const currentTaskId = String(plan.current_task_id ?? "");
|
|
515
|
+
if (!currentTaskId)
|
|
516
|
+
return [];
|
|
517
|
+
const tasks = Array.isArray(plan.tasks) ? plan.tasks : [];
|
|
518
|
+
const task = tasks.find((candidate) => isRecord(candidate) && candidate.id === currentTaskId);
|
|
519
|
+
if (!isRecord(task))
|
|
520
|
+
return [`current_task_id does not match a task: ${currentTaskId}`];
|
|
521
|
+
if (!Array.isArray(task.allowed_paths))
|
|
522
|
+
return [`${currentTaskId} must define allowed_paths`];
|
|
523
|
+
const patterns = task.allowed_paths.map((pattern) => String(pattern).replace("<harnessRoot>", ".codex"));
|
|
524
|
+
const changed = await changedFiles(projectRoot);
|
|
525
|
+
const blocked = changed.filter((file) => !matchesAny(file, patterns));
|
|
526
|
+
return blocked.length > 0 ? [`Changed files outside current task allowed_paths: ${blocked.join(", ")}`] : [];
|
|
527
|
+
}
|
|
528
|
+
function matchesAny(file, patterns) {
|
|
529
|
+
return patterns.some((pattern) => matchesGlob(file, pattern));
|
|
530
|
+
}
|
|
531
|
+
function matchesGlob(file, pattern) {
|
|
532
|
+
const normalizedFile = file.replace(/\\/g, "/");
|
|
533
|
+
const normalizedPattern = pattern.replace(/\\/g, "/");
|
|
534
|
+
if (normalizedFile === normalizedPattern)
|
|
535
|
+
return true;
|
|
536
|
+
if (normalizedPattern.endsWith("/**") && normalizedFile.startsWith(normalizedPattern.slice(0, -3)))
|
|
537
|
+
return true;
|
|
538
|
+
const escaped = normalizedPattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*");
|
|
539
|
+
return new RegExp(`^${escaped}$`).test(normalizedFile);
|
|
540
|
+
}
|
|
230
541
|
function isRecord(value) {
|
|
231
542
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
232
543
|
}
|
|
@@ -237,14 +548,46 @@ async function readYamlObject(filePath) {
|
|
|
237
548
|
}
|
|
238
549
|
async function markdownFiles(root) {
|
|
239
550
|
const files = await listFiles(root);
|
|
240
|
-
return files.filter((file) =>
|
|
551
|
+
return files.filter((file) => {
|
|
552
|
+
const name = path.basename(file).toLowerCase();
|
|
553
|
+
return file.endsWith(".md") && name !== "overview.md" && name !== "readme.md";
|
|
554
|
+
});
|
|
241
555
|
}
|
|
242
556
|
async function combinedText(files) {
|
|
243
557
|
const parts = await Promise.all(files.map((file) => readText(file)));
|
|
244
558
|
return parts.join("\n").toLowerCase();
|
|
245
559
|
}
|
|
246
560
|
function containsAny(text, needles) {
|
|
247
|
-
|
|
561
|
+
const lowered = text.toLowerCase();
|
|
562
|
+
return needles.some((needle) => lowered.includes(needle.toLowerCase()));
|
|
563
|
+
}
|
|
564
|
+
function isDevelopmentDraft(task) {
|
|
565
|
+
const taskId = String(task.id ?? "");
|
|
566
|
+
return Boolean(task.implementation_doc) || task.phase === "SPRINTING" || taskId.startsWith("DEV-");
|
|
567
|
+
}
|
|
568
|
+
function asStringList(value) {
|
|
569
|
+
if (Array.isArray(value)) {
|
|
570
|
+
return value.map((item) => String(item).trim()).filter(Boolean);
|
|
571
|
+
}
|
|
572
|
+
if (typeof value === "string" && value.trim())
|
|
573
|
+
return [value.trim()];
|
|
574
|
+
return [];
|
|
575
|
+
}
|
|
576
|
+
function normalizeDocRef(value) {
|
|
577
|
+
const normalized = value.replace(/\\/g, "/");
|
|
578
|
+
return normalized.startsWith("./") ? normalized.slice(2) : normalized;
|
|
579
|
+
}
|
|
580
|
+
function repoRelative(projectRoot, file) {
|
|
581
|
+
return path.relative(projectRoot, file).split(path.sep).join("/");
|
|
582
|
+
}
|
|
583
|
+
function taskText(task) {
|
|
584
|
+
const parts = ["id", "title", "summary", "phase"].map((key) => String(task[key] ?? "")).filter(Boolean);
|
|
585
|
+
if (isRecord(task.docs)) {
|
|
586
|
+
for (const value of Object.values(task.docs)) {
|
|
587
|
+
parts.push(...asStringList(value));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return parts.join("\n");
|
|
248
591
|
}
|
|
249
592
|
export async function changedFiles(projectRoot) {
|
|
250
593
|
try {
|