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.
- package/README.md +35 -10
- package/assets/agents/AGENTS_CORE.md +14 -9
- package/assets/docs/README.md +64 -11
- package/assets/make/sdlc-harness.mk +5 -1
- package/assets/policies/allowed_paths.yaml +9 -0
- package/assets/policies/gates.yaml +6 -0
- package/assets/policies/phase_contracts.yaml +49 -0
- package/assets/skills/pjsdlc_architect_design/SKILL.md +14 -8
- package/assets/skills/pjsdlc_dev_sprint/SKILL.md +8 -3
- package/assets/skills/pjsdlc_implementation_doc/SKILL.md +9 -4
- package/assets/skills/pjsdlc_manager/SKILL.md +17 -16
- package/assets/skills/pjsdlc_reviewer/SKILL.md +6 -1
- package/assets/skills/pjsdlc_rfc_recalibrate/SKILL.md +8 -5
- package/assets/skills/pjsdlc_tester/SKILL.md +12 -4
- package/assets/skills/pjsdlc_uiux_design/SKILL.md +76 -0
- package/assets/templates/PLAN_TEMPLATE.yaml +4 -0
- package/assets/templates/REVIEW_TEMPLATE.md +14 -4
- package/assets/templates/RFC_TEMPLATE.md +13 -5
- package/assets/templates/TECH_DESIGN_TEMPLATE.md +18 -10
- package/assets/templates/TEST_CASES_TEMPLATE.md +5 -3
- package/assets/templates/TEST_STRATEGY_TEMPLATE.md +4 -0
- package/assets/templates/UI_UX_DESIGN_TEMPLATE.md +67 -0
- package/assets/tools/harness_utils.py +92 -18
- package/assets/tools/transition.py +2 -1
- package/assets/tools/validate_allowed_paths.py +2 -2
- package/assets/tools/validate_design.py +56 -3
- package/assets/tools/validate_dev_state.py +1 -1
- package/assets/tools/validate_harness.py +17 -14
- package/assets/tools/validate_plan_draft.py +1 -1
- package/assets/tools/validate_prompt_language.py +17 -17
- package/assets/tools/validate_rfc.py +31 -0
- package/assets/tools/validate_test_plan.py +118 -1
- package/assets/tools/validate_uiux_design.py +101 -0
- package/dist/commands/index.js +5 -1
- package/dist/commands/inspect-workflow.d.ts +1 -0
- package/dist/commands/inspect-workflow.js +71 -0
- package/dist/lib/harness-root.js +5 -5
- package/dist/lib/init.js +7 -3
- package/dist/lib/validators.js +341 -27
- package/dist/lib/workflow-inspector.d.ts +35 -0
- package/dist/lib/workflow-inspector.js +340 -0
- package/package.json +2 -1
package/dist/lib/validators.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { promisify } from "node:util";
|
|
4
5
|
import { harnessPath, harnessRoot } from "./harness-root.js";
|
|
5
6
|
import { listFiles, pathExists, readText } from "./fs.js";
|
|
@@ -8,11 +9,11 @@ const execFileAsync = promisify(execFile);
|
|
|
8
9
|
const PARALLEL_MODES = new Set(["runtime_managed", "user_orchestrated"]);
|
|
9
10
|
const PARALLEL_TRIGGERS = new Set(["user_requested", "workflow_default"]);
|
|
10
11
|
const PARALLEL_RUNTIME_PROVIDERS = new Set(["codex_native_subagents", "user_orchestrated", "codex_exec_worktree"]);
|
|
11
|
-
const TASK_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
12
|
+
const TASK_PHASES = new Set(["REQUIREMENT_GATHERING", "UI_UX_DESIGNING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
12
13
|
const RESERVED_SUSPENDED_PHASE_TARGET = "<suspended_phase>";
|
|
13
14
|
const TRANSITION_KINDS = new Set(["normal", "return", "interrupt", "resume"]);
|
|
14
|
-
const PARALLEL_ALLOWED_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
15
|
-
const PARALLEL_READ_ONLY_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "REVIEWING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
15
|
+
const PARALLEL_ALLOWED_PHASES = new Set(["REQUIREMENT_GATHERING", "UI_UX_DESIGNING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
16
|
+
const PARALLEL_READ_ONLY_PHASES = new Set(["REQUIREMENT_GATHERING", "UI_UX_DESIGNING", "ARCHITECTING", "REVIEWING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
16
17
|
const PARALLEL_PROTECTED_WRITE_PATTERNS = [
|
|
17
18
|
".codex/state/**",
|
|
18
19
|
"<harnessRoot>/state/**",
|
|
@@ -86,8 +87,12 @@ const TESTING_DISALLOWED_CHANGED_PATHS = [...TESTING_DISALLOWED_ALLOWED_PATHS, "
|
|
|
86
87
|
const TESTING_RUNTIME_FILE_TERMS = ["bootstrap", "cloud", "daemon", "poller", "provider", "runtime", "service", "systemd"];
|
|
87
88
|
const TESTING_ALLOWED_TEST_FILE_TERMS = ["assertion", "fixture", "mock", "smoke"];
|
|
88
89
|
const TEST_REPORT_PATH = ".docs/07_test/TEST_REPORT.md";
|
|
90
|
+
const TEST_CASES_PATH = ".docs/07_test/TEST_CASES.md";
|
|
91
|
+
const EXPERIENCE_DOC_PREFIX = ".docs/02_experience/";
|
|
92
|
+
const DESIGN_SYSTEM_PATH = "DESIGN.md";
|
|
89
93
|
const CURRENT_RELEASE_REPORT_PATH = ".docs/08_release/CURRENT_RELEASE.md";
|
|
90
94
|
const TEST_REPORT_PLACEHOLDER_TERMS = ["pending", "tbd", "todo", "待填", "待补", "placeholder"];
|
|
95
|
+
const TEST_CASE_ID_PATTERN = /\bTC-\d{3,}\b/g;
|
|
91
96
|
const TEST_FACT_SOURCE_PHASES = new Set(["TESTING", "RFC_RECALIBRATION"]);
|
|
92
97
|
const TEST_FACT_SOURCE_PATTERNS = [".docs/07_test/**", ".docs/07_test/"];
|
|
93
98
|
const TEST_FACT_SOURCE_REF = /\.docs\/07_test\/[^\s`,)]+/g;
|
|
@@ -466,11 +471,47 @@ const RFC_SELF_TEST_TRIGGER_TERMS = [
|
|
|
466
471
|
"运行环境",
|
|
467
472
|
"阻塞"
|
|
468
473
|
];
|
|
474
|
+
const RFC_UIUX_TRIGGER_TERMS = [
|
|
475
|
+
"ui/ux",
|
|
476
|
+
"ux",
|
|
477
|
+
"screen",
|
|
478
|
+
"interaction",
|
|
479
|
+
"design.md",
|
|
480
|
+
"frontend",
|
|
481
|
+
"browser",
|
|
482
|
+
"page",
|
|
483
|
+
"visual",
|
|
484
|
+
"体验",
|
|
485
|
+
"屏幕",
|
|
486
|
+
"交互",
|
|
487
|
+
"视觉",
|
|
488
|
+
"前端"
|
|
489
|
+
];
|
|
490
|
+
const RFC_UIUX_IMPACT_TERMS = ["ui/ux impact", "体验影响"];
|
|
491
|
+
const UI_DRAFT_TASK_TERMS = [
|
|
492
|
+
"frontend",
|
|
493
|
+
"front-end",
|
|
494
|
+
"browser",
|
|
495
|
+
"page",
|
|
496
|
+
"screen",
|
|
497
|
+
"route",
|
|
498
|
+
"component",
|
|
499
|
+
"visual_ui",
|
|
500
|
+
"design.md",
|
|
501
|
+
".docs/02_experience/",
|
|
502
|
+
"页面",
|
|
503
|
+
"前端",
|
|
504
|
+
"屏幕",
|
|
505
|
+
"交互",
|
|
506
|
+
"组件",
|
|
507
|
+
"视觉"
|
|
508
|
+
];
|
|
469
509
|
const validators = {
|
|
470
510
|
"validate-harness": validateHarness,
|
|
471
511
|
"validate-current": validateCurrent,
|
|
472
512
|
"validate-plan": validatePlan,
|
|
473
513
|
"validate-pm": validatePm,
|
|
514
|
+
"validate-uiux": validateUiux,
|
|
474
515
|
"validate-design": validateDesign,
|
|
475
516
|
"validate-dev": validateDev,
|
|
476
517
|
"validate-review": validateReview,
|
|
@@ -495,6 +536,7 @@ async function validateHarness(projectRoot) {
|
|
|
495
536
|
for (const required of [
|
|
496
537
|
"AGENTS.md",
|
|
497
538
|
".docs/INDEX.md",
|
|
539
|
+
".docs/02_experience",
|
|
498
540
|
".docs/09_runbooks",
|
|
499
541
|
harnessPath(root, "config.yaml"),
|
|
500
542
|
harnessPath(root, "state", "lifecycle.yaml"),
|
|
@@ -521,6 +563,7 @@ async function validateCurrent(projectRoot) {
|
|
|
521
563
|
}
|
|
522
564
|
const gateByPhase = {
|
|
523
565
|
REQUIREMENT_GATHERING: "validate-pm",
|
|
566
|
+
UI_UX_DESIGNING: "validate-uiux",
|
|
524
567
|
ARCHITECTING: "validate-design",
|
|
525
568
|
SPRINTING: "validate-dev",
|
|
526
569
|
REVIEWING: "validate-review",
|
|
@@ -545,6 +588,48 @@ async function validatePm(projectRoot) {
|
|
|
545
588
|
errors.push("PRD must include open questions");
|
|
546
589
|
return { info: [`validate-pm checked ${docs.length} file(s)`], errors };
|
|
547
590
|
}
|
|
591
|
+
async function validateUiux(projectRoot) {
|
|
592
|
+
const root = await harnessRoot(projectRoot);
|
|
593
|
+
const lifecycle = await readYamlObject(path.join(projectRoot, root, "state", "lifecycle.yaml"));
|
|
594
|
+
const plan = await validatePlanState(projectRoot, String(lifecycle.current_phase ?? "") !== "UI_UX_DESIGNING");
|
|
595
|
+
const docs = await markdownFiles(path.join(projectRoot, ".docs/02_experience"));
|
|
596
|
+
const errors = [...plan.errors];
|
|
597
|
+
if (docs.length === 0)
|
|
598
|
+
errors.push("No UI/UX deliverables found in .docs/02_experience/");
|
|
599
|
+
let visualUi = false;
|
|
600
|
+
for (const doc of docs) {
|
|
601
|
+
const relative = repoRelative(projectRoot, doc);
|
|
602
|
+
const text = await readText(doc);
|
|
603
|
+
const notApplicable = containsAny(text, ["applicability: not_applicable", "applicability: `not_applicable`"]);
|
|
604
|
+
visualUi = visualUi || containsAny(text, ["applicability: visual_ui", "applicability: `visual_ui`"]);
|
|
605
|
+
if (notApplicable)
|
|
606
|
+
continue;
|
|
607
|
+
if (!containsAny(text, ["prd", "requirement", "需求"]))
|
|
608
|
+
errors.push(`${relative} must cite PRD and requirement IDs`);
|
|
609
|
+
if (!containsAny(text, ["user journey", "user journeys", "用户旅程"]))
|
|
610
|
+
errors.push(`${relative} must include user journeys`);
|
|
611
|
+
if (!containsAny(text, ["handoff matrix", "交接矩阵"]))
|
|
612
|
+
errors.push(`${relative} must include a handoff matrix`);
|
|
613
|
+
if (!containsAny(text, ["loading", "empty", "error", "success", "permission", "加载", "空状态", "错误", "成功", "权限"])) {
|
|
614
|
+
errors.push(`${relative} screen contracts must cover applicable loading/empty/error/success/permission states`);
|
|
615
|
+
}
|
|
616
|
+
if (!containsAny(text, ["responsive", "breakpoint", "响应式", "断点"]))
|
|
617
|
+
errors.push(`${relative} must include responsive acceptance`);
|
|
618
|
+
if (!containsAny(text, ["accessibility", "a11y", "focus", "keyboard", "touch", "无障碍", "焦点", "键盘", "触控"])) {
|
|
619
|
+
errors.push(`${relative} must include accessibility/focus/keyboard/touch expectations`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (visualUi) {
|
|
623
|
+
const designPath = path.join(projectRoot, DESIGN_SYSTEM_PATH);
|
|
624
|
+
if (!(await pathExists(designPath))) {
|
|
625
|
+
errors.push("visual UI experience requires root DESIGN.md");
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
errors.push(...(await validateDesignMd(projectRoot)));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return { info: [`validate-uiux checked ${docs.length} file(s)`], errors };
|
|
632
|
+
}
|
|
548
633
|
async function validateDesign(projectRoot) {
|
|
549
634
|
const root = await harnessRoot(projectRoot);
|
|
550
635
|
const lifecycle = await readYamlObject(path.join(projectRoot, root, "state", "lifecycle.yaml"));
|
|
@@ -552,6 +637,7 @@ async function validateDesign(projectRoot) {
|
|
|
552
637
|
const architecture = await markdownFiles(path.join(projectRoot, ".docs/02_architecture"));
|
|
553
638
|
const techPlan = await markdownFiles(path.join(projectRoot, ".docs/03_tech_plan"));
|
|
554
639
|
const product = await markdownFiles(path.join(projectRoot, ".docs/01_product"));
|
|
640
|
+
const experience = await markdownFiles(path.join(projectRoot, ".docs/02_experience"));
|
|
555
641
|
const text = await combinedText([...architecture, ...techPlan]);
|
|
556
642
|
const errors = [...plan.errors];
|
|
557
643
|
if (architecture.length === 0)
|
|
@@ -564,7 +650,7 @@ async function validateDesign(projectRoot) {
|
|
|
564
650
|
errors.push("Design must describe interfaces or contracts");
|
|
565
651
|
if (!containsAny(text, ["task", "任务", "breakdown"]))
|
|
566
652
|
errors.push("Design must include task breakdown");
|
|
567
|
-
const draft = await validateDesignDraft(projectRoot, root, techPlan);
|
|
653
|
+
const draft = await validateDesignDraft(projectRoot, root, techPlan, experience);
|
|
568
654
|
errors.push(...draft.errors);
|
|
569
655
|
errors.push(...(await validateCrossCuttingArchitecture(projectRoot, product, techPlan, architecture, draft.tasks)));
|
|
570
656
|
return { info: [`validate-design checked ${architecture.length + techPlan.length} file(s)`], errors };
|
|
@@ -574,7 +660,7 @@ async function validatePlan(projectRoot) {
|
|
|
574
660
|
const pathErrors = await validateChangedPaths(projectRoot, plan.plan, true);
|
|
575
661
|
return { info: [`validate-plan checked ${plan.taskCount} task(s)`], errors: [...plan.errors, ...pathErrors] };
|
|
576
662
|
}
|
|
577
|
-
async function validateDesignDraft(projectRoot, root, techPlanFiles) {
|
|
663
|
+
async function validateDesignDraft(projectRoot, root, techPlanFiles, experienceFiles) {
|
|
578
664
|
const errors = [];
|
|
579
665
|
const draft = await readYamlObject(path.join(projectRoot, root, "state", "plan.draft.yaml"));
|
|
580
666
|
if ("current_phase" in draft) {
|
|
@@ -590,6 +676,7 @@ async function validateDesignDraft(projectRoot, root, techPlanFiles) {
|
|
|
590
676
|
}
|
|
591
677
|
const tasks = rawTasks.filter(isRecord);
|
|
592
678
|
const availableTechPlans = new Set(techPlanFiles.map((file) => repoRelative(projectRoot, file)));
|
|
679
|
+
const availableExperienceDocs = new Set(experienceFiles.map((file) => repoRelative(projectRoot, file)));
|
|
593
680
|
const developmentTasks = [];
|
|
594
681
|
const primaryRefs = [];
|
|
595
682
|
for (const [index, rawTask] of rawTasks.entries()) {
|
|
@@ -622,6 +709,7 @@ async function validateDesignDraft(projectRoot, root, techPlanFiles) {
|
|
|
622
709
|
errors.push(`Draft task ${String(rawTask.id ?? "")} references missing or generated tech plan slice: ${ref}`);
|
|
623
710
|
}
|
|
624
711
|
}
|
|
712
|
+
errors.push(...(await validateUiuxDesignRefsForDraftTask(projectRoot, rawTask, availableExperienceDocs)));
|
|
625
713
|
errors.push(...(await validateSelfTestContractTechPlanBinding(projectRoot, rawTask, normalizedRefs)));
|
|
626
714
|
primaryRefs.push(normalizedRefs[0]);
|
|
627
715
|
}
|
|
@@ -633,6 +721,51 @@ async function validateDesignDraft(projectRoot, root, techPlanFiles) {
|
|
|
633
721
|
}
|
|
634
722
|
return { errors, tasks };
|
|
635
723
|
}
|
|
724
|
+
async function validateUiuxDesignRefsForDraftTask(projectRoot, task, availableExperienceDocs) {
|
|
725
|
+
const errors = [];
|
|
726
|
+
if (!isRecord(task.docs))
|
|
727
|
+
return errors;
|
|
728
|
+
const taskId = String(task.id ?? "");
|
|
729
|
+
const uiuxRefs = asStringList(task.docs.uiux).map(normalizeDocRef);
|
|
730
|
+
const designRefs = asStringList(task.docs.design_system).map(normalizeDocRef);
|
|
731
|
+
const uiTask = isUiDraftTask(task);
|
|
732
|
+
if (uiTask && uiuxRefs.length === 0) {
|
|
733
|
+
errors.push(`UI/frontend draft task ${taskId} must reference a UI/UX slice in docs.uiux`);
|
|
734
|
+
}
|
|
735
|
+
if (uiTask && designRefs.length === 0) {
|
|
736
|
+
errors.push(`UI/frontend draft task ${taskId} must reference DESIGN.md in docs.design_system`);
|
|
737
|
+
}
|
|
738
|
+
for (const ref of uiuxRefs) {
|
|
739
|
+
if (!ref.startsWith(EXPERIENCE_DOC_PREFIX)) {
|
|
740
|
+
errors.push(`Draft task ${taskId} docs.uiux must point into .docs/02_experience/: ${ref}`);
|
|
741
|
+
}
|
|
742
|
+
else if (!availableExperienceDocs.has(ref)) {
|
|
743
|
+
errors.push(`Draft task ${taskId} references missing or generated UI/UX slice: ${ref}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
for (const ref of designRefs) {
|
|
747
|
+
if (ref !== DESIGN_SYSTEM_PATH) {
|
|
748
|
+
errors.push(`Draft task ${taskId} docs.design_system must point to DESIGN.md: ${ref}`);
|
|
749
|
+
}
|
|
750
|
+
else if (!(await pathExists(path.join(projectRoot, DESIGN_SYSTEM_PATH)))) {
|
|
751
|
+
errors.push(`Draft task ${taskId} references missing design system: ${DESIGN_SYSTEM_PATH}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return errors;
|
|
755
|
+
}
|
|
756
|
+
function isUiDraftTask(task) {
|
|
757
|
+
const docsText = isRecord(task.docs)
|
|
758
|
+
? Object.values(task.docs)
|
|
759
|
+
.flatMap((value) => asStringList(value))
|
|
760
|
+
.join("\n")
|
|
761
|
+
: "";
|
|
762
|
+
const runtimeText = [task.target_runtime_environment, task.self_test_contract]
|
|
763
|
+
.filter(Boolean)
|
|
764
|
+
.map((value) => JSON.stringify(value))
|
|
765
|
+
.join("\n");
|
|
766
|
+
const text = [task.id, task.title, task.summary, task.phase, docsText, runtimeText].map((value) => String(value ?? "")).join("\n");
|
|
767
|
+
return containsAny(text, UI_DRAFT_TASK_TERMS);
|
|
768
|
+
}
|
|
636
769
|
function validateDraftTaskShape(task, index, errors) {
|
|
637
770
|
const prefix = `Task #${index + 1}`;
|
|
638
771
|
for (const field of ["id", "title", "status", "summary"]) {
|
|
@@ -815,6 +948,7 @@ async function validateTest(projectRoot) {
|
|
|
815
948
|
errors.push("Test report must include PASS/BLOCKED decision");
|
|
816
949
|
errors.push(...validateTestReadinessDecision(text));
|
|
817
950
|
errors.push(...validateRuntimeHandoffReport(report?.text ?? "", "Test report"));
|
|
951
|
+
errors.push(...(await validateTestCasesIfNeeded(projectRoot, plan.plan, report?.text ?? "")));
|
|
818
952
|
if (lifecycle.current_phase === "TESTING") {
|
|
819
953
|
errors.push(...testingBoundaryErrorsForChangedFiles(await changedFiles(projectRoot)));
|
|
820
954
|
}
|
|
@@ -872,6 +1006,7 @@ async function validateRfc(projectRoot) {
|
|
|
872
1006
|
if (invalidStatuses.length > 0)
|
|
873
1007
|
errors.push(`Invalid RFC status: ${invalidStatuses.join(", ")}`);
|
|
874
1008
|
errors.push(...(await validateRfcSelfTestImpact(projectRoot, docs)));
|
|
1009
|
+
errors.push(...(await validateRfcUiuxImpact(projectRoot, docs)));
|
|
875
1010
|
return { info: [`validate-rfc checked ${docs.length} file(s)`], errors };
|
|
876
1011
|
}
|
|
877
1012
|
async function validateRfcSelfTestImpact(projectRoot, docs) {
|
|
@@ -891,6 +1026,23 @@ async function validateRfcSelfTestImpact(projectRoot, docs) {
|
|
|
891
1026
|
}
|
|
892
1027
|
return errors;
|
|
893
1028
|
}
|
|
1029
|
+
async function validateRfcUiuxImpact(projectRoot, docs) {
|
|
1030
|
+
const errors = [];
|
|
1031
|
+
for (const doc of docs) {
|
|
1032
|
+
const relative = repoRelative(projectRoot, doc);
|
|
1033
|
+
const basename = path.basename(doc);
|
|
1034
|
+
const number = rfcNumber(basename);
|
|
1035
|
+
if (number !== undefined && number < 27)
|
|
1036
|
+
continue;
|
|
1037
|
+
const text = await readText(doc);
|
|
1038
|
+
if (!containsAny(text, RFC_UIUX_TRIGGER_TERMS))
|
|
1039
|
+
continue;
|
|
1040
|
+
if (!containsAny(text, RFC_UIUX_IMPACT_TERMS)) {
|
|
1041
|
+
errors.push(`${relative} must include UI/UX Impact when RFC changes experience docs, screen contracts, DESIGN.md, frontend, or browser behavior`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
return errors;
|
|
1045
|
+
}
|
|
894
1046
|
function rfcNumber(fileName) {
|
|
895
1047
|
const match = fileName.match(/^RFC[_-](\d+)/i);
|
|
896
1048
|
return match ? Number(match[1]) : undefined;
|
|
@@ -904,7 +1056,7 @@ async function validatePlanState(projectRoot, allowOpen) {
|
|
|
904
1056
|
if ("current_phase" in tasksData) {
|
|
905
1057
|
errors.push("plan.yaml must not define current_phase; lifecycle.yaml is the single source for current_phase");
|
|
906
1058
|
}
|
|
907
|
-
validateParallelExecutionContract(tasksData, currentPhase, errors);
|
|
1059
|
+
validateParallelExecutionContract(tasksData, currentPhase, errors, root);
|
|
908
1060
|
const tasks = Array.isArray(tasksData.tasks) ? tasksData.tasks : [];
|
|
909
1061
|
const nextTaskSequence = tasksData.next_task_sequence;
|
|
910
1062
|
if (!Number.isInteger(nextTaskSequence) || Number(nextTaskSequence) <= 0) {
|
|
@@ -1454,7 +1606,7 @@ async function validateSelfTestContractTechPlanBinding(projectRoot, task, normal
|
|
|
1454
1606
|
}
|
|
1455
1607
|
return errors;
|
|
1456
1608
|
}
|
|
1457
|
-
function validateParallelExecutionContract(plan, currentPhase, errors) {
|
|
1609
|
+
function validateParallelExecutionContract(plan, currentPhase, errors, root) {
|
|
1458
1610
|
const contract = plan.parallel_execution;
|
|
1459
1611
|
if (contract === undefined || contract === null)
|
|
1460
1612
|
return;
|
|
@@ -1484,7 +1636,7 @@ function validateParallelExecutionContract(plan, currentPhase, errors) {
|
|
|
1484
1636
|
errors.push("parallel_execution must not define linked_task_id; use plan.yaml current_task_id");
|
|
1485
1637
|
}
|
|
1486
1638
|
if (!PARALLEL_ALLOWED_PHASES.has(currentPhase)) {
|
|
1487
|
-
errors.push("parallel_execution is only supported during REQUIREMENT_GATHERING, ARCHITECTING, SPRINTING, REVIEWING, TESTING, RELEASING, or RFC_RECALIBRATION");
|
|
1639
|
+
errors.push("parallel_execution is only supported during REQUIREMENT_GATHERING, UI_UX_DESIGNING, ARCHITECTING, SPRINTING, REVIEWING, TESTING, RELEASING, or RFC_RECALIBRATION");
|
|
1488
1640
|
}
|
|
1489
1641
|
if (contract.coordinator !== "main_agent")
|
|
1490
1642
|
errors.push('parallel_execution.coordinator must be "main_agent"');
|
|
@@ -1539,8 +1691,8 @@ function validateParallelExecutionContract(plan, currentPhase, errors) {
|
|
|
1539
1691
|
if (!Array.isArray(worker.owned_paths) || worker.owned_paths.length === 0) {
|
|
1540
1692
|
errors.push(`${prefix}.owned_paths must not be empty when writes_repo is true`);
|
|
1541
1693
|
}
|
|
1542
|
-
validateParallelWorkerPathLock(plan, worker, index, errors);
|
|
1543
|
-
for (const owned of stringArray(worker.owned_paths).map(normalizeParallelPattern)) {
|
|
1694
|
+
validateParallelWorkerPathLock(plan, worker, index, errors, root);
|
|
1695
|
+
for (const owned of stringArray(worker.owned_paths).map((pattern) => normalizeParallelPattern(pattern, root))) {
|
|
1544
1696
|
writeOwnedPaths.push({ index, path: owned });
|
|
1545
1697
|
}
|
|
1546
1698
|
}
|
|
@@ -1549,7 +1701,7 @@ function validateParallelExecutionContract(plan, currentPhase, errors) {
|
|
|
1549
1701
|
for (let right = left + 1; right < writeOwnedPaths.length; right += 1) {
|
|
1550
1702
|
const leftOwned = writeOwnedPaths[left];
|
|
1551
1703
|
const rightOwned = writeOwnedPaths[right];
|
|
1552
|
-
if (globPatternsOverlap(leftOwned.path, rightOwned.path)) {
|
|
1704
|
+
if (globPatternsOverlap(leftOwned.path, rightOwned.path, root)) {
|
|
1553
1705
|
errors.push(`parallel_execution write worker owned_paths must not overlap: workers[${leftOwned.index}] ${leftOwned.path} vs workers[${rightOwned.index}] ${rightOwned.path}`);
|
|
1554
1706
|
}
|
|
1555
1707
|
}
|
|
@@ -1582,20 +1734,20 @@ function parallelRuntimeProvider(contract, errors) {
|
|
|
1582
1734
|
}
|
|
1583
1735
|
return String(runtime.provider ?? "");
|
|
1584
1736
|
}
|
|
1585
|
-
function validateParallelWorkerPathLock(plan, worker, index, errors) {
|
|
1737
|
+
function validateParallelWorkerPathLock(plan, worker, index, errors, root) {
|
|
1586
1738
|
const currentTask = currentPlanTask(plan);
|
|
1587
1739
|
if (!currentTask)
|
|
1588
1740
|
return;
|
|
1589
|
-
const taskAllowed = stringArray(currentTask.allowed_paths).map(normalizeParallelPattern);
|
|
1590
|
-
const workerOwned = stringArray(worker.owned_paths).map(normalizeParallelPattern);
|
|
1591
|
-
const workerForbidden = stringArray(worker.forbidden_paths).map(normalizeParallelPattern);
|
|
1592
|
-
const protectedPatterns = PARALLEL_PROTECTED_WRITE_PATTERNS.map(normalizeParallelPattern);
|
|
1741
|
+
const taskAllowed = stringArray(currentTask.allowed_paths).map((pattern) => normalizeParallelPattern(pattern, root));
|
|
1742
|
+
const workerOwned = stringArray(worker.owned_paths).map((pattern) => normalizeParallelPattern(pattern, root));
|
|
1743
|
+
const workerForbidden = stringArray(worker.forbidden_paths).map((pattern) => normalizeParallelPattern(pattern, root));
|
|
1744
|
+
const protectedPatterns = PARALLEL_PROTECTED_WRITE_PATTERNS.map((pattern) => normalizeParallelPattern(pattern, root));
|
|
1593
1745
|
for (const owned of workerOwned) {
|
|
1594
1746
|
if (!matchesAny(owned, taskAllowed)) {
|
|
1595
1747
|
errors.push(`parallel_execution.workers[${index}].owned_paths must be within current task allowed_paths: ${owned}`);
|
|
1596
1748
|
}
|
|
1597
1749
|
for (const forbidden of [...workerForbidden, ...protectedPatterns]) {
|
|
1598
|
-
if (globPatternsOverlap(owned, forbidden)) {
|
|
1750
|
+
if (globPatternsOverlap(owned, forbidden, root)) {
|
|
1599
1751
|
errors.push(`parallel_execution.workers[${index}].owned_paths must not overlap forbidden paths: ${owned} vs ${forbidden}`);
|
|
1600
1752
|
}
|
|
1601
1753
|
}
|
|
@@ -1609,22 +1761,22 @@ function currentPlanTask(plan) {
|
|
|
1609
1761
|
function stringArray(value) {
|
|
1610
1762
|
return Array.isArray(value) ? value.map((item) => String(item)) : [];
|
|
1611
1763
|
}
|
|
1612
|
-
function normalizeParallelPattern(pattern) {
|
|
1613
|
-
return pattern.replace(/\\/g, "/").replaceAll("<harnessRoot>",
|
|
1764
|
+
function normalizeParallelPattern(pattern, root) {
|
|
1765
|
+
return pattern.replace(/\\/g, "/").replaceAll("<harnessRoot>", root);
|
|
1614
1766
|
}
|
|
1615
|
-
function globPrefix(pattern) {
|
|
1616
|
-
const normalized = normalizeParallelPattern(pattern);
|
|
1767
|
+
function globPrefix(pattern, root) {
|
|
1768
|
+
const normalized = normalizeParallelPattern(pattern, root);
|
|
1617
1769
|
const positions = ["*", "[", "?"].map((token) => normalized.indexOf(token)).filter((index) => index >= 0);
|
|
1618
1770
|
const prefix = positions.length > 0 ? normalized.slice(0, Math.min(...positions)) : normalized;
|
|
1619
1771
|
return prefix.replace(/\/+$/, "");
|
|
1620
1772
|
}
|
|
1621
|
-
function globPatternsOverlap(left, right) {
|
|
1622
|
-
const leftClean = normalizeParallelPattern(left);
|
|
1623
|
-
const rightClean = normalizeParallelPattern(right);
|
|
1773
|
+
function globPatternsOverlap(left, right, root) {
|
|
1774
|
+
const leftClean = normalizeParallelPattern(left, root);
|
|
1775
|
+
const rightClean = normalizeParallelPattern(right, root);
|
|
1624
1776
|
if (matchesGlob(leftClean, rightClean) || matchesGlob(rightClean, leftClean))
|
|
1625
1777
|
return true;
|
|
1626
|
-
const leftPrefix = globPrefix(leftClean);
|
|
1627
|
-
const rightPrefix = globPrefix(rightClean);
|
|
1778
|
+
const leftPrefix = globPrefix(leftClean, root);
|
|
1779
|
+
const rightPrefix = globPrefix(rightClean, root);
|
|
1628
1780
|
if (!leftPrefix || !rightPrefix)
|
|
1629
1781
|
return leftPrefix === rightPrefix;
|
|
1630
1782
|
return leftPrefix === rightPrefix || leftPrefix.startsWith(`${rightPrefix}/`) || rightPrefix.startsWith(`${leftPrefix}/`);
|
|
@@ -1632,6 +1784,7 @@ function globPatternsOverlap(left, right) {
|
|
|
1632
1784
|
async function validateChangedPaths(projectRoot, plan, allowOpen) {
|
|
1633
1785
|
if (!allowOpen)
|
|
1634
1786
|
return [];
|
|
1787
|
+
const root = await harnessRoot(projectRoot);
|
|
1635
1788
|
const currentTaskId = String(plan.current_task_id ?? "");
|
|
1636
1789
|
if (!currentTaskId)
|
|
1637
1790
|
return [];
|
|
@@ -1641,7 +1794,7 @@ async function validateChangedPaths(projectRoot, plan, allowOpen) {
|
|
|
1641
1794
|
return [`current_task_id does not match a task: ${currentTaskId}`];
|
|
1642
1795
|
if (!Array.isArray(task.allowed_paths))
|
|
1643
1796
|
return [`${currentTaskId} must define allowed_paths`];
|
|
1644
|
-
const patterns = task.allowed_paths.map((pattern) => String(pattern).replace("<harnessRoot>",
|
|
1797
|
+
const patterns = task.allowed_paths.map((pattern) => String(pattern).replace("<harnessRoot>", root));
|
|
1645
1798
|
const changed = await changedFiles(projectRoot);
|
|
1646
1799
|
const blocked = changed.filter((file) => !matchesAny(file, patterns));
|
|
1647
1800
|
return blocked.length > 0 ? [`Changed files outside current task allowed_paths: ${blocked.join(", ")}`] : [];
|
|
@@ -2375,6 +2528,167 @@ function validateTestReadinessDecision(text) {
|
|
|
2375
2528
|
}
|
|
2376
2529
|
return [];
|
|
2377
2530
|
}
|
|
2531
|
+
async function validateDesignMd(projectRoot) {
|
|
2532
|
+
const errors = [];
|
|
2533
|
+
try {
|
|
2534
|
+
const cliPath = await findDesignMdCliPath(projectRoot);
|
|
2535
|
+
if (!cliPath) {
|
|
2536
|
+
errors.push("DESIGN.md linter not found; install package dependencies for @google/design.md");
|
|
2537
|
+
return errors;
|
|
2538
|
+
}
|
|
2539
|
+
const { stdout } = await execFileAsync(process.execPath, [cliPath, "lint", DESIGN_SYSTEM_PATH], { cwd: projectRoot });
|
|
2540
|
+
errors.push(...designMdLintErrors(stdout));
|
|
2541
|
+
}
|
|
2542
|
+
catch (caught) {
|
|
2543
|
+
const error = caught;
|
|
2544
|
+
const parsedErrors = designMdLintErrors(String(error.stdout ?? ""));
|
|
2545
|
+
if (parsedErrors.length > 0) {
|
|
2546
|
+
errors.push(...parsedErrors);
|
|
2547
|
+
}
|
|
2548
|
+
else {
|
|
2549
|
+
errors.push(`DESIGN.md linter failed: ${String(error.stderr ?? error.message ?? "unknown error").trim()}`);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
return errors;
|
|
2553
|
+
}
|
|
2554
|
+
async function findDesignMdCliPath(projectRoot) {
|
|
2555
|
+
const candidates = [];
|
|
2556
|
+
const addSearchRoots = (start) => {
|
|
2557
|
+
let current = path.resolve(start);
|
|
2558
|
+
while (true) {
|
|
2559
|
+
candidates.push(path.join(current, "node_modules", "@google", "design.md", "dist", "index.js"));
|
|
2560
|
+
const parent = path.dirname(current);
|
|
2561
|
+
if (parent === current)
|
|
2562
|
+
break;
|
|
2563
|
+
current = parent;
|
|
2564
|
+
}
|
|
2565
|
+
};
|
|
2566
|
+
addSearchRoots(projectRoot);
|
|
2567
|
+
addSearchRoots(path.dirname(fileURLToPath(import.meta.url)));
|
|
2568
|
+
for (const candidate of candidates) {
|
|
2569
|
+
if (await pathExists(candidate))
|
|
2570
|
+
return candidate;
|
|
2571
|
+
}
|
|
2572
|
+
return undefined;
|
|
2573
|
+
}
|
|
2574
|
+
function designMdLintErrors(stdout) {
|
|
2575
|
+
const text = stdout.trim();
|
|
2576
|
+
if (!text)
|
|
2577
|
+
return [];
|
|
2578
|
+
try {
|
|
2579
|
+
const diagnostics = JSON.parse(text);
|
|
2580
|
+
const errors = [];
|
|
2581
|
+
const summary = isRecord(diagnostics.summary) ? diagnostics.summary : {};
|
|
2582
|
+
if (Number(summary.errors ?? 0) > 0) {
|
|
2583
|
+
errors.push("DESIGN.md linter reported errors");
|
|
2584
|
+
}
|
|
2585
|
+
const findings = Array.isArray(diagnostics.findings) ? diagnostics.findings : [];
|
|
2586
|
+
for (const finding of findings) {
|
|
2587
|
+
if (!isRecord(finding) || String(finding.severity ?? "").toLowerCase() !== "error")
|
|
2588
|
+
continue;
|
|
2589
|
+
errors.push(String(finding.message ?? "DESIGN.md linter error"));
|
|
2590
|
+
}
|
|
2591
|
+
return errors;
|
|
2592
|
+
}
|
|
2593
|
+
catch {
|
|
2594
|
+
return [];
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
async function validateTestCasesIfNeeded(projectRoot, plan, reportText) {
|
|
2598
|
+
const casesPath = path.join(projectRoot, TEST_CASES_PATH);
|
|
2599
|
+
const shouldValidate = testCaseRefs(reportText).length > 0 || planReferencesTestCases(plan) || (await pathExists(casesPath));
|
|
2600
|
+
if (!shouldValidate)
|
|
2601
|
+
return [];
|
|
2602
|
+
if (!(await pathExists(casesPath))) {
|
|
2603
|
+
return [`Missing test cases: expected ${TEST_CASES_PATH} because TEST_REPORT.md or current TESTING task references test cases`];
|
|
2604
|
+
}
|
|
2605
|
+
return validateTestCases(await readText(casesPath), reportText);
|
|
2606
|
+
}
|
|
2607
|
+
function testCaseRefs(text) {
|
|
2608
|
+
return [...new Set(text.match(TEST_CASE_ID_PATTERN) ?? [])].sort();
|
|
2609
|
+
}
|
|
2610
|
+
function planReferencesTestCases(plan) {
|
|
2611
|
+
const tasks = Array.isArray(plan.tasks) ? plan.tasks : [];
|
|
2612
|
+
return tasks.some((task) => {
|
|
2613
|
+
if (!isRecord(task) || String(task.phase ?? "") !== "TESTING")
|
|
2614
|
+
return false;
|
|
2615
|
+
return asStringList(task.result_docs).includes(TEST_CASES_PATH);
|
|
2616
|
+
});
|
|
2617
|
+
}
|
|
2618
|
+
function validateTestCases(text, reportText) {
|
|
2619
|
+
const errors = [];
|
|
2620
|
+
if (containsAny(text, TEST_REPORT_PLACEHOLDER_TERMS)) {
|
|
2621
|
+
errors.push("TEST_CASES.md must not contain pending/TBD/TODO/placeholder content");
|
|
2622
|
+
}
|
|
2623
|
+
const lines = text.split(/\r?\n/);
|
|
2624
|
+
const rows = [];
|
|
2625
|
+
lines.forEach((line, index) => {
|
|
2626
|
+
if (!line.trim().startsWith("|") || !/\bTC-\d{3,}\b/.test(line))
|
|
2627
|
+
return;
|
|
2628
|
+
const headers = findMarkdownTableHeader(lines, index);
|
|
2629
|
+
if (!headers) {
|
|
2630
|
+
errors.push("TEST_CASES.md cases must be listed in a Markdown table with headers");
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
rows.push({ lineNumber: index + 1, headers, cells: splitMarkdownRow(line) });
|
|
2634
|
+
});
|
|
2635
|
+
TEST_CASE_ID_PATTERN.lastIndex = 0;
|
|
2636
|
+
const ids = rows.flatMap((row) => testCaseRefs(row.cells.join("|")));
|
|
2637
|
+
if (ids.length === 0) {
|
|
2638
|
+
errors.push("TEST_CASES.md must include at least one TC-* case");
|
|
2639
|
+
}
|
|
2640
|
+
const duplicates = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))].sort();
|
|
2641
|
+
if (duplicates.length > 0) {
|
|
2642
|
+
errors.push(`TEST_CASES.md Case ID must be unique: ${duplicates.join(", ")}`);
|
|
2643
|
+
}
|
|
2644
|
+
for (const row of rows) {
|
|
2645
|
+
const requirement = headerIndex(row.headers, ["requirement", "risk", "需求", "风险"]);
|
|
2646
|
+
const runnableEntry = headerIndex(row.headers, ["runnable entry", "runnable", "entry", "入口"]);
|
|
2647
|
+
const steps = headerIndex(row.headers, ["steps", "步骤"]);
|
|
2648
|
+
const expectedExit = headerIndex(row.headers, ["expected exit", "expected result", "expected", "预期", "出口"]);
|
|
2649
|
+
const missing = [];
|
|
2650
|
+
if (!requiredCell(row.cells, requirement))
|
|
2651
|
+
missing.push("Requirement / Risk Ref");
|
|
2652
|
+
if (!requiredCell(row.cells, runnableEntry))
|
|
2653
|
+
missing.push("Runnable Entry");
|
|
2654
|
+
if (!requiredCell(row.cells, steps))
|
|
2655
|
+
missing.push("Steps");
|
|
2656
|
+
if (!requiredCell(row.cells, expectedExit))
|
|
2657
|
+
missing.push("Expected Exit");
|
|
2658
|
+
if (missing.length > 0) {
|
|
2659
|
+
errors.push(`TEST_CASES.md row ${row.lineNumber} missing required case fields: ${missing.join(", ")}`);
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
const missingRefs = testCaseRefs(reportText).filter((id) => !ids.includes(id));
|
|
2663
|
+
if (missingRefs.length > 0) {
|
|
2664
|
+
errors.push(`TEST_REPORT.md references case IDs not found in TEST_CASES.md: ${[...new Set(missingRefs)].join(", ")}`);
|
|
2665
|
+
}
|
|
2666
|
+
return errors;
|
|
2667
|
+
}
|
|
2668
|
+
function splitMarkdownRow(line) {
|
|
2669
|
+
return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
|
|
2670
|
+
}
|
|
2671
|
+
function isMarkdownSeparatorRow(line) {
|
|
2672
|
+
const stripped = line.trim();
|
|
2673
|
+
return stripped.startsWith("|") && /^[|:\-\s]+$/.test(stripped);
|
|
2674
|
+
}
|
|
2675
|
+
function findMarkdownTableHeader(lines, rowIndex) {
|
|
2676
|
+
for (let index = rowIndex - 1; index > 0; index -= 1) {
|
|
2677
|
+
if (!isMarkdownSeparatorRow(lines[index]))
|
|
2678
|
+
continue;
|
|
2679
|
+
const header = lines[index - 1];
|
|
2680
|
+
return header.trim().startsWith("|") ? splitMarkdownRow(header) : undefined;
|
|
2681
|
+
}
|
|
2682
|
+
return undefined;
|
|
2683
|
+
}
|
|
2684
|
+
function headerIndex(headers, terms) {
|
|
2685
|
+
return headers.findIndex((header) => terms.some((term) => header.toLowerCase().includes(term.toLowerCase())));
|
|
2686
|
+
}
|
|
2687
|
+
function requiredCell(cells, index) {
|
|
2688
|
+
if (index === undefined || index < 0 || index >= cells.length)
|
|
2689
|
+
return "";
|
|
2690
|
+
return cells[index].trim();
|
|
2691
|
+
}
|
|
2378
2692
|
function validateRuntimeHandoffReport(text, label) {
|
|
2379
2693
|
const decision = finalDecision(text);
|
|
2380
2694
|
if (decision !== "PASS")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type InspectionDecision = "PASS" | "WARN" | "BLOCKED";
|
|
2
|
+
export type InspectionDataSource = "measured" | "inferred" | "self_reported" | "unavailable";
|
|
3
|
+
export interface WorkflowInspectionOptions {
|
|
4
|
+
recentMinutes?: number;
|
|
5
|
+
recentTurns?: number;
|
|
6
|
+
estimatedTokens?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface WorkflowInspectionMetric {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
value: string | number | boolean | null;
|
|
12
|
+
level: InspectionDecision;
|
|
13
|
+
data_source: InspectionDataSource;
|
|
14
|
+
details: string;
|
|
15
|
+
}
|
|
16
|
+
export interface WorkflowInspectionFinding {
|
|
17
|
+
severity: InspectionDecision;
|
|
18
|
+
code: string;
|
|
19
|
+
message: string;
|
|
20
|
+
recommendation: string;
|
|
21
|
+
data_source: InspectionDataSource;
|
|
22
|
+
}
|
|
23
|
+
export interface WorkflowInspectionReport {
|
|
24
|
+
decision: InspectionDecision;
|
|
25
|
+
harness_root: string;
|
|
26
|
+
harness_root_source: string;
|
|
27
|
+
current_phase: string;
|
|
28
|
+
current_task_id: string;
|
|
29
|
+
inspected_at: string;
|
|
30
|
+
metrics: WorkflowInspectionMetric[];
|
|
31
|
+
findings: WorkflowInspectionFinding[];
|
|
32
|
+
}
|
|
33
|
+
export declare function runWorkflowInspection(projectRoot: string, options?: WorkflowInspectionOptions): Promise<WorkflowInspectionReport>;
|
|
34
|
+
export declare function renderWorkflowInspection(report: WorkflowInspectionReport): string;
|
|
35
|
+
export declare function renderWorkflowInspectionPrompt(report: WorkflowInspectionReport): string;
|