agent-project-sdlc 0.1.24 → 0.1.26

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.
Files changed (42) hide show
  1. package/README.md +35 -10
  2. package/assets/agents/AGENTS_CORE.md +14 -9
  3. package/assets/docs/README.md +64 -11
  4. package/assets/make/sdlc-harness.mk +5 -1
  5. package/assets/policies/allowed_paths.yaml +9 -0
  6. package/assets/policies/gates.yaml +6 -0
  7. package/assets/policies/phase_contracts.yaml +49 -0
  8. package/assets/skills/pjsdlc_architect_design/SKILL.md +14 -8
  9. package/assets/skills/pjsdlc_dev_sprint/SKILL.md +8 -3
  10. package/assets/skills/pjsdlc_implementation_doc/SKILL.md +9 -4
  11. package/assets/skills/pjsdlc_manager/SKILL.md +17 -16
  12. package/assets/skills/pjsdlc_reviewer/SKILL.md +6 -1
  13. package/assets/skills/pjsdlc_rfc_recalibrate/SKILL.md +8 -5
  14. package/assets/skills/pjsdlc_tester/SKILL.md +12 -4
  15. package/assets/skills/pjsdlc_uiux_design/SKILL.md +76 -0
  16. package/assets/templates/PLAN_TEMPLATE.yaml +4 -0
  17. package/assets/templates/REVIEW_TEMPLATE.md +14 -4
  18. package/assets/templates/RFC_TEMPLATE.md +13 -5
  19. package/assets/templates/TECH_DESIGN_TEMPLATE.md +18 -10
  20. package/assets/templates/TEST_CASES_TEMPLATE.md +5 -3
  21. package/assets/templates/TEST_STRATEGY_TEMPLATE.md +4 -0
  22. package/assets/templates/UI_UX_DESIGN_TEMPLATE.md +67 -0
  23. package/assets/tools/harness_utils.py +92 -18
  24. package/assets/tools/transition.py +2 -1
  25. package/assets/tools/validate_allowed_paths.py +2 -2
  26. package/assets/tools/validate_design.py +56 -3
  27. package/assets/tools/validate_dev_state.py +1 -1
  28. package/assets/tools/validate_harness.py +17 -14
  29. package/assets/tools/validate_plan_draft.py +1 -1
  30. package/assets/tools/validate_prompt_language.py +17 -17
  31. package/assets/tools/validate_rfc.py +31 -0
  32. package/assets/tools/validate_test_plan.py +118 -1
  33. package/assets/tools/validate_uiux_design.py +101 -0
  34. package/dist/commands/index.js +5 -1
  35. package/dist/commands/inspect-workflow.d.ts +1 -0
  36. package/dist/commands/inspect-workflow.js +71 -0
  37. package/dist/lib/harness-root.js +5 -5
  38. package/dist/lib/init.js +7 -3
  39. package/dist/lib/validators.js +341 -27
  40. package/dist/lib/workflow-inspector.d.ts +35 -0
  41. package/dist/lib/workflow-inspector.js +340 -0
  42. package/package.json +2 -1
@@ -0,0 +1,340 @@
1
+ import path from "node:path";
2
+ import { harnessPath, readHarnessRootConfig } from "./harness-root.js";
3
+ import { pathExists, readText } from "./fs.js";
4
+ import { parseYaml } from "./yaml.js";
5
+ import { runValidator } from "./validators.js";
6
+ const OPEN_TASK_STATUSES = new Set(["pending", "in_progress", "blocked", "pending_revision"]);
7
+ const HIGH_RISK_EVIDENCE_LEVELS = new Set(["external_provider_live", "deployed_runtime", "business_handoff_ready"]);
8
+ const HIGH_RISK_TARGETS = new Set(["cloud_vm", "managed_service", "browser", "worker"]);
9
+ export async function runWorkflowInspection(projectRoot, options = {}) {
10
+ const rootConfig = await readHarnessRootConfig(projectRoot);
11
+ const root = rootConfig.harnessFolderName;
12
+ const inspectedAt = new Date().toISOString();
13
+ const metrics = [];
14
+ const findings = [];
15
+ const lifecyclePath = path.join(projectRoot, harnessPath(root, "state", "lifecycle.yaml"));
16
+ const planPath = path.join(projectRoot, harnessPath(root, "state", "plan.yaml"));
17
+ const lifecycle = await readYamlObject(lifecyclePath);
18
+ const plan = await readYamlObject(planPath);
19
+ const currentPhase = String(lifecycle?.current_phase ?? "");
20
+ const currentTaskId = String(plan?.current_task_id ?? "");
21
+ const tasks = arrayOfRecords(plan?.tasks);
22
+ const openTasks = tasks.filter((task) => OPEN_TASK_STATUSES.has(String(task.status ?? "")));
23
+ const currentTask = currentTaskId ? tasks.find((task) => String(task.id ?? "") === currentTaskId) : undefined;
24
+ const inferredTask = currentTask ?? (openTasks.length === 1 ? openTasks[0] : undefined);
25
+ const planText = await readIfExists(planPath);
26
+ addMetric(metrics, findings, "workflow_weight.plan_lines", "plan.yaml line count", planText ? countLines(planText) : null, levelByThreshold(planText ? countLines(planText) : 0, 200, 500), "measured", "Active plan should stay short enough for an Agent to recover current work without reading historical execution flow.", "Keep only current/future task contracts in plan.yaml; move completed history to implementation docs, git or external release records.");
27
+ addMetric(metrics, findings, "workflow_weight.open_tasks", "open task count", openTasks.length, openTasks.length <= 1 ? "PASS" : "BLOCKED", "measured", "The Harness expects one active stage task at a time; multiple open tasks make recovery and allowed_paths ambiguous.", "Split sequentially or choose one current task, then remove or defer the other open task contracts.");
28
+ const docRefs = inferredTask ? collectTaskDocRefs(inferredTask) : [];
29
+ addMetric(metrics, findings, "workflow_weight.current_task_doc_refs", "current task document refs", inferredTask ? docRefs.length : null, levelByThreshold(docRefs.length, 5, 10), inferredTask ? "measured" : "unavailable", inferredTask
30
+ ? "Too many task-scoped docs usually means the Agent must hydrate too much context before acting."
31
+ : "No current/open task is selected, so task-scoped document weight is not measurable.", "Narrow the current task result_docs / implementation_doc / docs refs to the smallest handoff surface.");
32
+ const allowedPaths = inferredTask ? asStringList(inferredTask.allowed_paths) : [];
33
+ addMetric(metrics, findings, "workflow_weight.allowed_paths", "current task allowed_paths", inferredTask ? allowedPaths.length : null, levelByThreshold(allowedPaths.length, 12, 25), inferredTask ? "measured" : "unavailable", inferredTask
34
+ ? "Wide allowed_paths increase blast radius and make it harder to tell whether work stayed inside the task contract."
35
+ : "No current/open task is selected, so allowed_paths weight is not measurable.", "Split the task or replace broad globs with the concrete files/directories needed for this step.");
36
+ const workingNotesCount = inferredTask ? countWorkingNotes(inferredTask.working_notes) : null;
37
+ addMetric(metrics, findings, "workflow_weight.working_notes", "current task working_notes", workingNotesCount, workingNotesCount === null ? "PASS" : workingNotesCount <= 8 ? "PASS" : "BLOCKED", inferredTask ? "measured" : "unavailable", inferredTask
38
+ ? "working_notes is recovery-first scratch state; more than eight items usually means historical flow is leaking into active context."
39
+ : "No current/open task is selected, so working_notes weight is not measurable.", "Collapse notes to resume state, next step, blocker, last passed gate and do-not-retry facts; move history elsewhere.");
40
+ await addSelfTestReportMetric(projectRoot, inferredTask, metrics, findings);
41
+ await addLargestDocMetric(projectRoot, docRefs, metrics, findings);
42
+ await addValidatorMetric(projectRoot, "validate-harness", metrics, findings);
43
+ await addValidatorMetric(projectRoot, "validate-plan", metrics, findings);
44
+ await addTestingReadinessMetric(projectRoot, currentPhase, metrics, findings);
45
+ addLifecycleMetric(lifecycle, plan, currentPhase, currentTaskId, currentTask, openTasks, metrics, findings);
46
+ addRecoveryMetric(plan, inferredTask, metrics, findings);
47
+ addManualMetrics(options, metrics, findings);
48
+ const decision = combineDecision(metrics.map((metric) => metric.level));
49
+ return {
50
+ decision,
51
+ harness_root: root,
52
+ harness_root_source: rootConfig.source,
53
+ current_phase: currentPhase || "UNKNOWN",
54
+ current_task_id: currentTaskId,
55
+ inspected_at: inspectedAt,
56
+ metrics,
57
+ findings
58
+ };
59
+ }
60
+ export function renderWorkflowInspection(report) {
61
+ const lines = [
62
+ `Workflow inspection: ${report.decision}`,
63
+ `harness root: ${report.harness_root} (${report.harness_root_source})`,
64
+ `current phase: ${report.current_phase}`,
65
+ `current task: ${report.current_task_id || "(none)"}`,
66
+ "",
67
+ "Metrics:",
68
+ ...report.metrics.map((metric) => {
69
+ const value = metric.value === null ? "unknown" : String(metric.value);
70
+ return `- ${metric.level} ${metric.id}: ${value} [${metric.data_source}] - ${metric.details}`;
71
+ })
72
+ ];
73
+ if (report.findings.length > 0) {
74
+ lines.push("", "Findings:");
75
+ for (const finding of report.findings) {
76
+ lines.push(`- ${finding.severity} ${finding.code}: ${finding.message}`);
77
+ lines.push(` next: ${finding.recommendation}`);
78
+ }
79
+ }
80
+ return `${lines.join("\n")}\n`;
81
+ }
82
+ export function renderWorkflowInspectionPrompt(report) {
83
+ return [
84
+ "# Workflow Self-Inspection Prompt",
85
+ "",
86
+ "你是用户仓库里的 Harness workflow self-inspection agent。请结合上面的 measured / inferred 指标和你最近一次真实执行经历,判断工作流是否符合预期。",
87
+ "",
88
+ "必须区分数据来源:",
89
+ "- measured: 脚本真实读到的文件、字段、validator 结果或命令耗时。",
90
+ "- inferred: 脚本只能从体量、字段缺失、重复或过长现象推断。",
91
+ "- self_reported: 你根据最近一次执行过程填写的耗时、turns、估算 token 和反复回读情况。",
92
+ "- unavailable: 当前环境没有 telemetry,不能伪造精确 token 或真实执行耗时。",
93
+ "",
94
+ "请回答:",
95
+ "1. 当前 workflow 从哪里进、当前任务是什么、下一步是什么?如果不能在 2 分钟内回答,记为 WARN。",
96
+ "2. 最近一次只是为了理解 workflow / 找事实源花了多少分钟、多少轮对话、估算多少 tokens?>15 分钟或 >10k tokens 记 WARN;>30 分钟或 >20k tokens 记 BLOCKED 候选。",
97
+ "3. 是否有会改变下一步动作的判断只埋在 notes/evidence/appendix/long doc 中,而没有 promoted 到 hard constraint、do_not_retry 或短 runbook 顶部?",
98
+ "4. 当前 Development Self-Test Report / TEST_CASES / TEST_REPORT 是否像可执行交接卡,而不是 debug log、operator log、evidence dump 或历史流水?",
99
+ "5. Review / Testing 是否能直接消费入口、核心路径、checkpoint、observable exit 和 evidence refs,而不用重新发明 runtime?",
100
+ "",
101
+ "输出格式:",
102
+ "- Decision: PASS | WARN | BLOCKED",
103
+ "- Data gaps: 列出 unavailable 或只能 self_reported 的指标",
104
+ "- Findings: 每条注明 measured / inferred / self_reported / unavailable",
105
+ "- Next action: 最小修复动作,不要扩展工作流体量",
106
+ "",
107
+ `Current script decision: ${report.decision}`,
108
+ `Current phase: ${report.current_phase}`,
109
+ `Current task: ${report.current_task_id || "(none)"}`
110
+ ].join("\n");
111
+ }
112
+ async function addValidatorMetric(projectRoot, gate, metrics, findings) {
113
+ try {
114
+ const report = await runValidator(projectRoot, gate);
115
+ addMetric(metrics, findings, `fact_source_alignment.${gate}`, gate, report.errors.length, report.errors.length === 0 ? "PASS" : "BLOCKED", "measured", report.errors.length === 0 ? `${gate} reported no errors.` : report.errors.join("; "), `Run npx sdlc-harness ${gate} and fix the reported source-of-truth drift.`);
116
+ }
117
+ catch (error) {
118
+ addMetric(metrics, findings, `fact_source_alignment.${gate}`, gate, null, "BLOCKED", "measured", error instanceof Error ? error.message : String(error), `Restore files required by ${gate}, then rerun inspect-workflow.`);
119
+ }
120
+ }
121
+ async function addTestingReadinessMetric(projectRoot, currentPhase, metrics, findings) {
122
+ const reportPath = path.join(projectRoot, ".docs/07_test/TEST_REPORT.md");
123
+ const casesPath = path.join(projectRoot, ".docs/07_test/TEST_CASES.md");
124
+ const shouldValidate = currentPhase === "TESTING" || (await pathExists(reportPath)) || (await pathExists(casesPath));
125
+ if (!shouldValidate) {
126
+ addMetric(metrics, findings, "testing_readiness.validate-test", "validate-test readiness", null, "PASS", "unavailable", "No TESTING fact source exists yet; validate-test readiness is not evaluated for this phase.", "Create TEST_CASES.md / TEST_REPORT.md only when TESTING has executable entry/exit facts.");
127
+ return;
128
+ }
129
+ try {
130
+ const report = await runValidator(projectRoot, "validate-test");
131
+ const level = report.errors.length === 0 ? "PASS" : currentPhase === "TESTING" ? "BLOCKED" : "WARN";
132
+ addMetric(metrics, findings, "testing_readiness.validate-test", "validate-test readiness", report.errors.length, level, "measured", report.errors.length === 0 ? "TESTING fact sources are structurally consumable." : report.errors.join("; "), "Keep TEST_CASES as reusable test design and TEST_REPORT as execution evidence; fix missing or drifting TC references.");
133
+ }
134
+ catch (error) {
135
+ addMetric(metrics, findings, "testing_readiness.validate-test", "validate-test readiness", null, currentPhase === "TESTING" ? "BLOCKED" : "WARN", "measured", error instanceof Error ? error.message : String(error), "Restore TESTING fact sources or remove stale partial test files until TESTING is in scope.");
136
+ }
137
+ }
138
+ function addLifecycleMetric(lifecycle, plan, currentPhase, currentTaskId, currentTask, openTasks, metrics, findings) {
139
+ let level = "PASS";
140
+ const problems = [];
141
+ if (!currentPhase) {
142
+ level = "BLOCKED";
143
+ problems.push("lifecycle.yaml does not define current_phase");
144
+ }
145
+ if (plan && "current_phase" in plan) {
146
+ level = "BLOCKED";
147
+ problems.push("plan.yaml duplicates current_phase");
148
+ }
149
+ if (currentTaskId && !currentTask) {
150
+ level = "BLOCKED";
151
+ problems.push("current_task_id does not match any task");
152
+ }
153
+ if (!currentTaskId && openTasks.length === 1 && currentPhase !== "COMPLETED") {
154
+ level = maxLevel(level, "WARN");
155
+ problems.push("one open task exists but current_task_id is empty");
156
+ }
157
+ const allowedNext = Array.isArray(lifecycle?.allowed_next_phases) ? lifecycle?.allowed_next_phases.length : 0;
158
+ if (allowedNext === 0 && !["COMPLETED", "UNKNOWN"].includes(currentPhase || "UNKNOWN")) {
159
+ level = maxLevel(level, "WARN");
160
+ problems.push("allowed_next_phases is empty");
161
+ }
162
+ addMetric(metrics, findings, "handoff_clarity.lifecycle", "lifecycle and current task clarity", problems.length, level, "measured", problems.length === 0 ? "Lifecycle and current task pointers are coherent." : problems.join("; "), "Keep current_phase only in lifecycle.yaml, current task only in plan.yaml, and regenerate allowed_next_phases through transition.py.");
163
+ }
164
+ function addRecoveryMetric(plan, currentTask, metrics, findings) {
165
+ if (!currentTask || !isHighRiskTask(currentTask)) {
166
+ addMetric(metrics, findings, "recovery_safety.resume_capsule", "high-risk resume capsule", null, "PASS", currentTask ? "inferred" : "unavailable", currentTask ? "Current task is not classified as high-risk by evidence_level or target_runtime_environment." : "No current/open task is selected.", "When a task becomes live/runtime/high-risk, add resume_capsule with canonical path, next step, blocker, do-not-retry and recovery refs.");
167
+ return;
168
+ }
169
+ const capsule = isRecord(plan?.resume_capsule) ? plan?.resume_capsule : undefined;
170
+ const problems = [];
171
+ if (!capsule)
172
+ problems.push("resume_capsule missing");
173
+ if (capsule && asStringList(capsule.do_not_retry).length === 0)
174
+ problems.push("do_not_retry missing");
175
+ if (capsule && asStringList(capsule.recovery_refs).length === 0)
176
+ problems.push("recovery_refs missing");
177
+ addMetric(metrics, findings, "recovery_safety.resume_capsule", "high-risk resume capsule", problems.length, problems.length === 0 ? "PASS" : "BLOCKED", "measured", problems.length === 0 ? "High-risk task has resume-first recovery state." : problems.join("; "), "Record resume_capsule before continuing high-risk runtime/live work.");
178
+ }
179
+ function addManualMetrics(options, metrics, findings) {
180
+ addManualMetric(metrics, findings, "self_reported.recent_minutes", "recent workflow minutes", options.recentMinutes, 15, 30);
181
+ addManualMetric(metrics, findings, "self_reported.recent_turns", "recent workflow turns", options.recentTurns, 6, 10);
182
+ addManualMetric(metrics, findings, "self_reported.estimated_tokens", "estimated workflow tokens", options.estimatedTokens, 10000, 20000);
183
+ if (options.estimatedTokens === undefined) {
184
+ addMetric(metrics, findings, "workflow_weight.actual_tokens", "actual model tokens", null, "PASS", "unavailable", "No local token telemetry was provided; inspect-workflow will not invent a precise token number.", "Pass --estimated-tokens when the Agent/client has a reliable estimate, or answer the --prompt self-check.");
185
+ }
186
+ }
187
+ function addManualMetric(metrics, findings, id, label, value, warnThreshold, blockThreshold) {
188
+ if (value === undefined)
189
+ return;
190
+ addMetric(metrics, findings, id, label, value, levelByThreshold(value, warnThreshold, blockThreshold), "self_reported", `${label} was supplied by the user/Agent, not measured from local files.`, "If this is WARN/BLOCKED, reduce workflow context weight before continuing.");
191
+ }
192
+ async function addSelfTestReportMetric(projectRoot, currentTask, metrics, findings) {
193
+ const implementationDoc = typeof currentTask?.implementation_doc === "string" ? currentTask.implementation_doc.trim() : "";
194
+ if (!implementationDoc) {
195
+ addMetric(metrics, findings, "workflow_weight.self_test_report_lines", "Development Self-Test Report lines", null, "PASS", "unavailable", "No current implementation_doc is selected, so self-test report size is not measurable.", "When SPRINTING is active, keep the report as a short handoff card.");
196
+ return;
197
+ }
198
+ const fullPath = path.join(projectRoot, implementationDoc);
199
+ const text = await readIfExists(fullPath);
200
+ if (!text) {
201
+ addMetric(metrics, findings, "workflow_weight.self_test_report_lines", "Development Self-Test Report lines", null, "BLOCKED", "measured", `implementation_doc is missing: ${implementationDoc}`, "Create or point to the current task implementation doc.");
202
+ return;
203
+ }
204
+ const section = markdownSection(text, ["development self-test report", "开发自测报告"]);
205
+ if (!section) {
206
+ addMetric(metrics, findings, "workflow_weight.self_test_report_lines", "Development Self-Test Report lines", null, "PASS", "unavailable", "No Development Self-Test Report section is present in the current implementation doc.", "Add the report when the current task has a self_test_contract.");
207
+ return;
208
+ }
209
+ const lines = countLines(section);
210
+ const highRisk = isHighRiskTask(currentTask);
211
+ const level = levelByThreshold(lines, highRisk ? 120 : 80, highRisk ? 180 : 120);
212
+ addMetric(metrics, findings, "workflow_weight.self_test_report_lines", "Development Self-Test Report lines", lines, level, "measured", highRisk ? "High-risk report line count uses 120/180 thresholds." : "Ordinary report line count uses 80/120 thresholds.", "Move debug/operator/evidence/exploration detail to runbook, evidence index or appendix; keep the report as a handoff card.");
213
+ }
214
+ async function addLargestDocMetric(projectRoot, docRefs, metrics, findings) {
215
+ let largest = 0;
216
+ let largestRef = "";
217
+ for (const ref of docRefs) {
218
+ const text = await readIfExists(path.join(projectRoot, ref));
219
+ if (!text)
220
+ continue;
221
+ const lines = countLines(text);
222
+ if (lines > largest) {
223
+ largest = lines;
224
+ largestRef = ref;
225
+ }
226
+ }
227
+ addMetric(metrics, findings, "drift_risk.largest_current_doc_lines", "largest current task doc lines", largestRef ? largest : null, largest > 700 ? "WARN" : largest > 300 ? "WARN" : "PASS", largestRef ? "inferred" : "unavailable", largestRef ? `${largestRef} is the largest current task doc.` : "No current task document refs were available.", "If a current handoff doc keeps growing, split durable decisions into ADRs, runtime steps into runbooks, and evidence bodies into evidence indexes.");
228
+ }
229
+ function addMetric(metrics, findings, id, label, value, level, dataSource, details, recommendation) {
230
+ metrics.push({ id, label, value, level, data_source: dataSource, details });
231
+ if (level !== "PASS") {
232
+ findings.push({
233
+ severity: level,
234
+ code: id,
235
+ message: details,
236
+ recommendation,
237
+ data_source: dataSource
238
+ });
239
+ }
240
+ }
241
+ async function readYamlObject(filePath) {
242
+ if (!(await pathExists(filePath)))
243
+ return undefined;
244
+ const parsed = parseYaml(await readText(filePath));
245
+ return isRecord(parsed) ? parsed : undefined;
246
+ }
247
+ async function readIfExists(filePath) {
248
+ return (await pathExists(filePath)) ? readText(filePath) : undefined;
249
+ }
250
+ function levelByThreshold(value, warnThreshold, blockThreshold) {
251
+ if (value > blockThreshold)
252
+ return "BLOCKED";
253
+ if (value > warnThreshold)
254
+ return "WARN";
255
+ return "PASS";
256
+ }
257
+ function combineDecision(levels) {
258
+ if (levels.includes("BLOCKED"))
259
+ return "BLOCKED";
260
+ if (levels.includes("WARN"))
261
+ return "WARN";
262
+ return "PASS";
263
+ }
264
+ function maxLevel(left, right) {
265
+ return combineDecision([left, right]);
266
+ }
267
+ function countLines(text) {
268
+ if (!text)
269
+ return 0;
270
+ return text.split(/\r?\n/).length;
271
+ }
272
+ function countWorkingNotes(value) {
273
+ if (Array.isArray(value))
274
+ return value.length;
275
+ if (typeof value === "string" && value.trim())
276
+ return 1;
277
+ return 0;
278
+ }
279
+ function collectTaskDocRefs(task) {
280
+ const refs = new Set();
281
+ for (const ref of asStringList(task.implementation_doc))
282
+ refs.add(normalizeDocRef(ref));
283
+ for (const ref of asStringList(task.result_docs))
284
+ refs.add(normalizeDocRef(ref));
285
+ if (isRecord(task.docs)) {
286
+ for (const value of Object.values(task.docs)) {
287
+ for (const ref of asStringList(value))
288
+ refs.add(normalizeDocRef(ref));
289
+ }
290
+ }
291
+ return [...refs].filter(Boolean);
292
+ }
293
+ function normalizeDocRef(ref) {
294
+ return ref.replace(/\\/g, "/").replace(/^\.\//, "");
295
+ }
296
+ function arrayOfRecords(value) {
297
+ return Array.isArray(value) ? value.filter(isRecord) : [];
298
+ }
299
+ function asStringList(value) {
300
+ if (Array.isArray(value))
301
+ return value.map((item) => String(item).trim()).filter(Boolean);
302
+ if (typeof value === "string" && value.trim())
303
+ return [value.trim()];
304
+ return [];
305
+ }
306
+ function isRecord(value) {
307
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
308
+ }
309
+ function isHighRiskTask(task) {
310
+ const evidence = isRecord(task.evidence_level) ? String(task.evidence_level.required ?? "") : "";
311
+ const target = isRecord(task.target_runtime_environment) ? String(task.target_runtime_environment.kind ?? "") : "";
312
+ return HIGH_RISK_EVIDENCE_LEVELS.has(evidence) || HIGH_RISK_TARGETS.has(target);
313
+ }
314
+ function markdownSection(text, headerTerms) {
315
+ const lines = text.split(/\r?\n/);
316
+ let start = -1;
317
+ let level = 0;
318
+ for (let index = 0; index < lines.length; index += 1) {
319
+ const match = lines[index].match(/^(#{1,6})\s+(.+)$/);
320
+ if (!match)
321
+ continue;
322
+ const title = match[2].toLowerCase();
323
+ if (headerTerms.some((term) => title.includes(term.toLowerCase()))) {
324
+ start = index;
325
+ level = match[1].length;
326
+ break;
327
+ }
328
+ }
329
+ if (start === -1)
330
+ return undefined;
331
+ let end = lines.length;
332
+ for (let index = start + 1; index < lines.length; index += 1) {
333
+ const match = lines[index].match(/^(#{1,6})\s+/);
334
+ if (match && match[1].length <= level) {
335
+ end = index;
336
+ break;
337
+ }
338
+ }
339
+ return lines.slice(start, end).join("\n");
340
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-project-sdlc",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "CLI and canonical assets for the AI SDLC Harness workflow.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "node": ">=20"
24
24
  },
25
25
  "dependencies": {
26
+ "@google/design.md": "^0.2.0",
26
27
  "yaml": "^2.9.0"
27
28
  },
28
29
  "devDependencies": {