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.
@@ -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 PARALLEL_PHASES = new Set(["REQUIREMENT_GATHERING", "SPRINTING", "TESTING"]);
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
- validateParallelExecutionContract(tasksData, errors);
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
- for (const task of tasks) {
104
- for (const field of ["id", "title", "status", "summary", "implementation_doc"]) {
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
- const match = taskId.match(/^DEV-(\d+)$/);
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[field])
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
- return { info: [`validate-dev checked ${tasks.length} task(s)`], errors };
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 (!PARALLEL_PHASES.has(String(contract.phase ?? ""))) {
157
- errors.push("parallel_execution.phase must be REQUIREMENT_GATHERING, SPRINTING, or TESTING");
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 (contract.phase === "SPRINTING") {
162
- if (!contract.linked_task_id)
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) => file.endsWith(".md") && !file.endsWith("overview.md"));
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
- return needles.some((needle) => text.includes(needle.toLowerCase()));
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-project-sdlc",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "CLI and canonical assets for the AI SDLC Harness workflow.",
5
5
  "type": "module",
6
6
  "bin": {