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
@@ -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>", ".codex");
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>", ".codex"));
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;