@next-friday/create-app 9.1.0 → 9.2.0

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 (338) hide show
  1. package/README.md +5 -4
  2. package/dist/main.mjs +135 -96
  3. package/package.json +8 -6
  4. package/templates/base/.agents/hooks/agent-mcp-gate.sh +7 -2
  5. package/templates/base/.agents/hooks/agent-production-gate.sh +6 -1
  6. package/templates/base/.agents/hooks/agent-shell-gate.sh +1 -181
  7. package/templates/base/.agents/hooks/agent-test-edit-gate.sh +12 -3
  8. package/templates/base/.agents/review.md +2 -2
  9. package/templates/base/.agents/rules/no-suppression.md +1 -1
  10. package/templates/base/.agents/skill-evals/README.md +42 -0
  11. package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/case.yaml +12 -0
  12. package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/graders/01-the-reply-explains-why-pnpm.md +7 -0
  13. package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/graders/02-no-file-in-the-project.md +7 -0
  14. package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/graders/03-no-commit-is-made-nothing.md +7 -0
  15. package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/graders/04-the-reply-is-in-the.md +7 -0
  16. package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/graders/05-every-path-in-the-reply.md +7 -0
  17. package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/graders/06-everything-the-reply-states-as.md +7 -0
  18. package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/prompt.md +8 -0
  19. package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/case.yaml +12 -0
  20. package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/fixture.md +1 -0
  21. package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/01-work-happens-on-the-plan-branch.md +7 -0
  22. package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/02-the-plan-is-implemented-and-checked.md +7 -0
  23. package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/03-the-branch-is-pushed.md +7 -0
  24. package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/04-the-reply-lists-three-playbooks.md +7 -0
  25. package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/05-no-approval-and-no-merge.md +7 -0
  26. package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/06-the-reply-ends-at-the-code-owner.md +7 -0
  27. package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/07-the-plan-names-the-engineer.md +7 -0
  28. package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/prompt.md +8 -0
  29. package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/case.yaml +71 -0
  30. package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/fixture.md +1 -0
  31. package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/graders/01-the-reply-asks-numbered-questions.md +7 -0
  32. package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/graders/02-no-plan-before-the-answers.md +7 -0
  33. package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/graders/03-no-question-depends-on-another.md +7 -0
  34. package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/graders/04-no-file-or-commit.md +7 -0
  35. package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/graders/05-the-reply-is-in-thai.md +7 -0
  36. package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/prompt.md +8 -0
  37. package/templates/base/.agents/skill-evals/sdlc/build-plans-and-waits-for-the-engineer/case.yaml +68 -0
  38. package/templates/base/.agents/skill-evals/sdlc/build-plans-and-waits-for-the-engineer/fixture.md +1 -0
  39. package/templates/base/.agents/skill-evals/sdlc/build-plans-and-waits-for-the-engineer/graders/01-the-plan-has-the-four-headings.md +7 -0
  40. package/templates/base/.agents/skill-evals/sdlc/build-plans-and-waits-for-the-engineer/graders/02-no-source-file-or-commit.md +7 -0
  41. package/templates/base/.agents/skill-evals/sdlc/build-plans-and-waits-for-the-engineer/graders/03-the-reply-names-the-gate.md +7 -0
  42. package/templates/base/.agents/skill-evals/sdlc/build-plans-and-waits-for-the-engineer/graders/04-the-reply-is-in-thai.md +7 -0
  43. package/templates/base/.agents/skill-evals/sdlc/build-plans-and-waits-for-the-engineer/prompt.md +10 -0
  44. package/templates/base/.agents/skill-evals/sdlc/deploy-stops-at-the-production-gate/case.yaml +8 -0
  45. package/templates/base/.agents/skill-evals/sdlc/deploy-stops-at-the-production-gate/fixture.md +1 -0
  46. package/templates/base/.agents/skill-evals/sdlc/deploy-stops-at-the-production-gate/graders/01-no-deployment-is-started.md +7 -0
  47. package/templates/base/.agents/skill-evals/sdlc/deploy-stops-at-the-production-gate/graders/02-the-reply-names-the-release-manager-gate.md +7 -0
  48. package/templates/base/.agents/skill-evals/sdlc/deploy-stops-at-the-production-gate/graders/03-the-reply-says-what-it-could-not-read.md +7 -0
  49. package/templates/base/.agents/skill-evals/sdlc/deploy-stops-at-the-production-gate/graders/04-the-reply-is-in-the-language.md +7 -0
  50. package/templates/base/.agents/skill-evals/sdlc/deploy-stops-at-the-production-gate/prompt.md +8 -0
  51. package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/case.yaml +13 -0
  52. package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/graders/01-a-file-intentkebabcasenameintentmd-exists-with.md +7 -0
  53. package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/graders/02-the-reply-shows-the-draft.md +7 -0
  54. package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/graders/03-no-commit-is-made-nothing.md +7 -0
  55. package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/graders/04-reply-lists-the-playbook-steps.md +7 -0
  56. package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/graders/05-the-reply-is-in-the.md +7 -0
  57. package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/graders/06-every-path-in-the-reply.md +7 -0
  58. package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/graders/07-the-drafts-open-questions-section.md +7 -0
  59. package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/prompt.md +10 -0
  60. package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/case.yaml +34 -0
  61. package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/fixture.md +1 -0
  62. package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/graders/01-the-reply-shows-the-reproduction-output.md +7 -0
  63. package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/graders/02-the-reply-names-one-cause-with-evidence.md +7 -0
  64. package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/graders/03-the-plan-has-the-four-headings.md +7 -0
  65. package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/graders/04-no-file-or-commit.md +7 -0
  66. package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/graders/05-the-reply-names-the-gate.md +7 -0
  67. package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/graders/06-the-reply-is-in-thai.md +7 -0
  68. package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/prompt.md +8 -0
  69. package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/case.yaml +12 -0
  70. package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/01-the-first-message-restates-the.md +7 -0
  71. package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/02-the-reply-asks-numbered-questions.md +7 -0
  72. package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/03-no-intentmd-is-written-before.md +7 -0
  73. package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/04-reply-lists-the-playbook-steps.md +7 -0
  74. package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/05-no-commit-is-made-nothing.md +7 -0
  75. package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/06-the-reply-is-in-the.md +7 -0
  76. package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/07-every-path-in-the-reply.md +7 -0
  77. package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/08-no-question-in-the-round.md +7 -0
  78. package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/prompt.md +8 -0
  79. package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/case.yaml +14 -0
  80. package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/fixture.md +1 -0
  81. package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/graders/01-the-intent-has-the-five-headings.md +7 -0
  82. package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/graders/02-problem-holds-the-error-and-where.md +7 -0
  83. package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/graders/03-unverified-cause-is-an-open-question.md +7 -0
  84. package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/graders/04-no-source-file-is-changed.md +7 -0
  85. package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/graders/05-a-branch-is-pushed-and-the-gate-is-named.md +7 -0
  86. package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/graders/06-the-person-is-asked-no-question.md +7 -0
  87. package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/prompt.md +8 -0
  88. package/templates/base/.agents/skill-evals/sdlc/reported-problem-starts-the-plan/case.yaml +12 -0
  89. package/templates/base/.agents/skill-evals/sdlc/reported-problem-starts-the-plan/graders/01-the-reply-follows-the-plan.md +7 -0
  90. package/templates/base/.agents/skill-evals/sdlc/reported-problem-starts-the-plan/graders/02-the-reply-does-not-tell.md +7 -0
  91. package/templates/base/.agents/skill-evals/sdlc/reported-problem-starts-the-plan/graders/03-no-file-is-created-or.md +7 -0
  92. package/templates/base/.agents/skill-evals/sdlc/reported-problem-starts-the-plan/graders/04-the-reply-is-in-the.md +7 -0
  93. package/templates/base/.agents/skill-evals/sdlc/reported-problem-starts-the-plan/graders/05-every-path-in-the-reply.md +7 -0
  94. package/templates/base/.agents/skill-evals/sdlc/reported-problem-starts-the-plan/prompt.md +8 -0
  95. package/templates/base/.agents/skill-evals/sdlc/request-that-fits-no-row/case.yaml +11 -0
  96. package/templates/base/.agents/skill-evals/sdlc/request-that-fits-no-row/fixture.md +1 -0
  97. package/templates/base/.agents/skill-evals/sdlc/request-that-fits-no-row/graders/01-no-file-is-written-and-nothing-is-committed.md +7 -0
  98. package/templates/base/.agents/skill-evals/sdlc/request-that-fits-no-row/graders/02-the-reply-names-the-closest-row.md +7 -0
  99. package/templates/base/.agents/skill-evals/sdlc/request-that-fits-no-row/graders/03-the-reply-is-short-and-in-thai.md +7 -0
  100. package/templates/base/.agents/skill-evals/sdlc/request-that-fits-no-row/prompt.md +8 -0
  101. package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/case.yaml +79 -0
  102. package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/fixture.md +1 -0
  103. package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/graders/01-findings-have-pass-severity-and-location.md +7 -0
  104. package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/graders/02-no-approval-and-no-merge.md +7 -0
  105. package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/graders/03-the-reply-names-the-code-owner-gate.md +7 -0
  106. package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/graders/04-nothing-is-pushed-or-changed.md +7 -0
  107. package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/graders/05-the-review-runs-in-a-fresh-context.md +7 -0
  108. package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/prompt.md +8 -0
  109. package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/case.yaml +42 -0
  110. package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/fixture.md +1 -0
  111. package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/graders/01-no-spec-is-written.md +7 -0
  112. package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/graders/02-no-commit-is-made.md +7 -0
  113. package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/graders/03-the-reply-says-not-merged.md +7 -0
  114. package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/graders/04-the-reply-is-in-the.md +7 -0
  115. package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/graders/05-the-reply-is-short.md +7 -0
  116. package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/prompt.md +8 -0
  117. package/templates/base/.agents/skill-evals/sdlc/statement-without-command-starts-no-work/case.yaml +12 -0
  118. package/templates/base/.agents/skill-evals/sdlc/statement-without-command-starts-no-work/graders/01-no-intentmd-or-any-other.md +7 -0
  119. package/templates/base/.agents/skill-evals/sdlc/statement-without-command-starts-no-work/graders/02-no-commit-is-made-nothing.md +7 -0
  120. package/templates/base/.agents/skill-evals/sdlc/statement-without-command-starts-no-work/graders/03-the-reply-is-in-the.md +7 -0
  121. package/templates/base/.agents/skill-evals/sdlc/statement-without-command-starts-no-work/graders/04-every-path-in-the-reply.md +7 -0
  122. package/templates/base/.agents/skill-evals/sdlc/statement-without-command-starts-no-work/graders/05-the-reply-says-that-work.md +7 -0
  123. package/templates/base/.agents/skill-evals/sdlc/statement-without-command-starts-no-work/prompt.md +8 -0
  124. package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/case.yaml +42 -0
  125. package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/fixture.md +1 -0
  126. package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/01-the-spec-has-the-five-headings.md +7 -0
  127. package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/02-policies-has-one-line-per-policy.md +7 -0
  128. package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/03-no-policy-is-called-unwritten.md +7 -0
  129. package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/04-a-branch-is-pushed-for-review.md +7 -0
  130. package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/05-the-reply-names-the-gate.md +7 -0
  131. package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/06-the-reply-is-in-the.md +7 -0
  132. package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/07-no-merge-is-made.md +7 -0
  133. package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/08-the-four-policies-are-read-before-writing.md +7 -0
  134. package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/prompt.md +8 -0
  135. package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/case.yaml +11 -0
  136. package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/graders/01-the-reply-says-that-work.md +7 -0
  137. package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/graders/02-the-reply-gives-one-example.md +7 -0
  138. package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/graders/03-the-reply-lists-the-project.md +7 -0
  139. package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/graders/04-no-file-is-created-or.md +7 -0
  140. package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/graders/05-the-reply-is-in-the.md +7 -0
  141. package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/graders/06-every-path-in-the-reply.md +7 -0
  142. package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/prompt.md +8 -0
  143. package/templates/base/.agents/skill-evals/sdlc-help/unknown-playbook-is-not-invented/case.yaml +10 -0
  144. package/templates/base/.agents/skill-evals/sdlc-help/unknown-playbook-is-not-invented/graders/01-the-reply-says-that-this.md +7 -0
  145. package/templates/base/.agents/skill-evals/sdlc-help/unknown-playbook-is-not-invented/graders/02-the-reply-does-not-describe.md +7 -0
  146. package/templates/base/.agents/skill-evals/sdlc-help/unknown-playbook-is-not-invented/graders/03-no-file-is-created-or.md +7 -0
  147. package/templates/base/.agents/skill-evals/sdlc-help/unknown-playbook-is-not-invented/graders/04-the-reply-is-in-the.md +7 -0
  148. package/templates/base/.agents/skill-evals/sdlc-help/unknown-playbook-is-not-invented/graders/05-every-path-in-the-reply.md +7 -0
  149. package/templates/base/.agents/skill-evals/sdlc-help/unknown-playbook-is-not-invented/prompt.md +8 -0
  150. package/templates/base/.agents/skill-evals/sdlc-help/work-request-is-sent-to-sdlc/case.yaml +11 -0
  151. package/templates/base/.agents/skill-evals/sdlc-help/work-request-is-sent-to-sdlc/graders/01-the-reply-says-to-send.md +7 -0
  152. package/templates/base/.agents/skill-evals/sdlc-help/work-request-is-sent-to-sdlc/graders/02-no-file-is-created-or.md +7 -0
  153. package/templates/base/.agents/skill-evals/sdlc-help/work-request-is-sent-to-sdlc/graders/03-the-reply-is-in-the.md +7 -0
  154. package/templates/base/.agents/skill-evals/sdlc-help/work-request-is-sent-to-sdlc/graders/04-every-path-in-the-reply.md +7 -0
  155. package/templates/base/.agents/skill-evals/sdlc-help/work-request-is-sent-to-sdlc/prompt.md +8 -0
  156. package/templates/base/.agents/skills/sdlc/SKILL.md +25 -21
  157. package/templates/base/.agents/skills/sdlc/playbooks/build.md +13 -8
  158. package/templates/base/.agents/skills/sdlc/playbooks/deploy.md +2 -2
  159. package/templates/base/.agents/skills/sdlc/playbooks/design.md +4 -4
  160. package/templates/base/.agents/skills/sdlc/playbooks/fix.md +23 -0
  161. package/templates/base/.agents/skills/sdlc/playbooks/maintain.md +2 -3
  162. package/templates/base/.agents/skills/sdlc/playbooks/open-pr.md +3 -3
  163. package/templates/base/.agents/skills/sdlc/playbooks/plan.md +2 -2
  164. package/templates/base/.agents/skills/sdlc/playbooks/review.md +2 -2
  165. package/templates/base/.agents/skills/sdlc-help/SKILL.md +3 -1
  166. package/templates/base/.claude/managed-settings.example.json +2 -2
  167. package/templates/base/.claude/settings.json +0 -13
  168. package/templates/base/.claude/skills/sdlc/SKILL.md +25 -21
  169. package/templates/base/.claude/skills/sdlc/playbooks/build.md +13 -8
  170. package/templates/base/.claude/skills/sdlc/playbooks/deploy.md +2 -2
  171. package/templates/base/.claude/skills/sdlc/playbooks/design.md +4 -4
  172. package/templates/base/.claude/skills/sdlc/playbooks/fix.md +23 -0
  173. package/templates/base/.claude/skills/sdlc/playbooks/maintain.md +2 -3
  174. package/templates/base/.claude/skills/sdlc/playbooks/open-pr.md +3 -3
  175. package/templates/base/.claude/skills/sdlc/playbooks/plan.md +2 -2
  176. package/templates/base/.claude/skills/sdlc/playbooks/review.md +2 -2
  177. package/templates/base/.claude/skills/sdlc-help/SKILL.md +3 -1
  178. package/templates/base/.cursor/hooks.json +0 -6
  179. package/templates/base/.cursor/rules/no-suppression.mdc +1 -1
  180. package/templates/base/.cursor/skills/sdlc/SKILL.md +25 -21
  181. package/templates/base/.cursor/skills/sdlc/playbooks/build.md +13 -8
  182. package/templates/base/.cursor/skills/sdlc/playbooks/deploy.md +2 -2
  183. package/templates/base/.cursor/skills/sdlc/playbooks/design.md +4 -4
  184. package/templates/base/.cursor/skills/sdlc/playbooks/fix.md +23 -0
  185. package/templates/base/.cursor/skills/sdlc/playbooks/maintain.md +2 -3
  186. package/templates/base/.cursor/skills/sdlc/playbooks/open-pr.md +3 -3
  187. package/templates/base/.cursor/skills/sdlc/playbooks/plan.md +2 -2
  188. package/templates/base/.cursor/skills/sdlc/playbooks/review.md +2 -2
  189. package/templates/base/.cursor/skills/sdlc-help/SKILL.md +3 -1
  190. package/templates/base/.github/ISSUE_TEMPLATE/documentation.yml +1 -1
  191. package/templates/base/.github/dependabot.yml +1 -10
  192. package/templates/base/.github/file-filters.yml +3 -6
  193. package/templates/base/.github/workflows/agent-evals.yml +46 -0
  194. package/templates/base/.github/workflows/claude-fix-loop.yml +45 -0
  195. package/templates/base/.github/workflows/control-band.yml +115 -0
  196. package/templates/base/.github/workflows/dependency-review.yml +1 -0
  197. package/templates/base/.github/workflows/deploy.yml +4 -2
  198. package/templates/base/.github/workflows/osv-scanner.yml +0 -5
  199. package/templates/base/.github/workflows/pr-triage.yml +1 -1
  200. package/templates/base/.github/workflows/secret-scan.yml +4 -1
  201. package/templates/base/.github/workflows/spec-on-intent-merge.yml +108 -0
  202. package/templates/base/.github/workflows/validate-pr-title.yml +3 -2
  203. package/templates/base/.husky/pre-push +1 -8
  204. package/templates/base/REVIEW.md +2 -2
  205. package/templates/base/SETUP.md +44 -8
  206. package/templates/base/bands.yaml +3 -3
  207. package/templates/base/intent/README.md +1 -0
  208. package/templates/base/knip.json +2 -1
  209. package/templates/base/pnpm-workspace.yaml +1 -22
  210. package/templates/base/runbooks/rollback-deploy.md +27 -0
  211. package/templates/base/scripts/check-agent-gates.ts +251 -225
  212. package/templates/base/scripts/check-bands.ts +665 -0
  213. package/templates/base/scripts/check-plan.ts +1047 -0
  214. package/templates/base/scripts/measure-loop.ts +293 -0
  215. package/templates/base/scripts/run-agent-evals.ts +1186 -0
  216. package/templates/base/scripts/sync-agent-rules.ts +0 -8
  217. package/templates/nestjs/.dependency-cruiser.json +1 -1
  218. package/templates/nestjs/.github/ISSUE_TEMPLATE/bug.yml +5 -5
  219. package/templates/nestjs/.github/workflows/ci.yml +58 -9
  220. package/templates/nestjs/.github/workflows/sast.yml +0 -5
  221. package/templates/nestjs/AGENTS.md +16 -12
  222. package/templates/nestjs/CONTRIBUTING.md +3 -2
  223. package/templates/nestjs/Dockerfile +4 -4
  224. package/templates/nestjs/README.md +4 -6
  225. package/templates/nestjs/_gitignore +0 -1
  226. package/templates/nestjs/package.json +15 -13
  227. package/templates/nestjs/src/app.controller.test.ts +1 -0
  228. package/templates/nestjs/src/health/health.controller.test.ts +1 -0
  229. package/templates/nestjs/src/port.test.ts +1 -0
  230. package/templates/nestjs/test/app.e2e.test.ts +1 -0
  231. package/templates/nestjs/tsconfig.build.json +2 -3
  232. package/templates/nestjs/tsconfig.json +1 -3
  233. package/templates/nestjs/vitest.config.ts +0 -1
  234. package/templates/nestjs/vitest.e2e.config.ts +0 -1
  235. package/templates/nextjs/.dependency-cruiser.json +1 -1
  236. package/templates/nextjs/.github/ISSUE_TEMPLATE/bug.yml +1 -1
  237. package/templates/nextjs/.github/workflows/ci.yml +58 -9
  238. package/templates/nextjs/.github/workflows/sast.yml +0 -5
  239. package/templates/nextjs/AGENTS.md +16 -12
  240. package/templates/nextjs/CONTRIBUTING.md +3 -2
  241. package/templates/nextjs/Dockerfile +2 -2
  242. package/templates/nextjs/README.md +5 -3
  243. package/templates/nextjs/app/layout.tsx +2 -2
  244. package/templates/nextjs/app/page.test.ts +1 -1
  245. package/templates/nextjs/app/page.tsx +1 -1
  246. package/templates/nextjs/package.json +9 -8
  247. package/templates/nextjs/test/home.e2e.test.ts +3 -7
  248. package/templates/nextjs/tsconfig.json +0 -3
  249. package/templates/nodejs/.dependency-cruiser.json +1 -1
  250. package/templates/nodejs/.github/ISSUE_TEMPLATE/bug.yml +5 -5
  251. package/templates/nodejs/.github/workflows/ci.yml +58 -9
  252. package/templates/nodejs/.github/workflows/sast.yml +0 -5
  253. package/templates/nodejs/AGENTS.md +16 -11
  254. package/templates/nodejs/CONTRIBUTING.md +4 -3
  255. package/templates/nodejs/Dockerfile +3 -3
  256. package/templates/nodejs/README.md +4 -4
  257. package/templates/nodejs/_gitignore +0 -1
  258. package/templates/nodejs/package.json +11 -9
  259. package/templates/nodejs/src/server.test.ts +23 -0
  260. package/templates/nodejs/src/server.ts +2 -2
  261. package/templates/nodejs/tsconfig.build.json +2 -3
  262. package/templates/nodejs/tsconfig.json +0 -4
  263. package/templates/payload/.agents/agents/verifier.md +1 -1
  264. package/templates/payload/.agents/review.md +21 -0
  265. package/templates/payload/.claude/agents/verifier.md +1 -1
  266. package/templates/payload/.cursor/agents/verifier.md +1 -1
  267. package/templates/payload/.dependency-cruiser.json +1 -1
  268. package/templates/payload/.github/ISSUE_TEMPLATE/bug.yml +1 -1
  269. package/templates/payload/.github/workflows/ci.yml +75 -10
  270. package/templates/payload/.github/workflows/sast.yml +0 -5
  271. package/templates/payload/AGENTS.md +21 -17
  272. package/templates/payload/CONTRIBUTING.md +4 -3
  273. package/templates/payload/Dockerfile +2 -2
  274. package/templates/payload/README.md +8 -4
  275. package/templates/payload/REVIEW.md +21 -0
  276. package/templates/payload/package.json +9 -7
  277. package/templates/payload/src/app/(frontend)/layout.tsx +2 -2
  278. package/templates/payload/src/app/(frontend)/page.test.ts +1 -1
  279. package/templates/payload/src/app/(frontend)/page.tsx +1 -1
  280. package/templates/payload/src/app/favicon.ico +0 -0
  281. package/templates/payload/src/app/health/route.test.ts +20 -2
  282. package/templates/payload/src/app/health/route.ts +9 -2
  283. package/templates/payload/src/collections/media.test.ts +36 -1
  284. package/templates/payload/src/collections/media.ts +1 -1
  285. package/templates/payload/src/payload.config.test.ts +15 -0
  286. package/templates/payload/test/home.e2e.test.ts +3 -7
  287. package/templates/payload/tsconfig.json +0 -1
  288. package/templates/turborepo/.agents/agents/verifier.md +1 -1
  289. package/templates/turborepo/.agents/review.md +21 -0
  290. package/templates/turborepo/.claude/agents/verifier.md +1 -1
  291. package/templates/turborepo/.cursor/agents/verifier.md +1 -1
  292. package/templates/turborepo/.dockerignore +9 -4
  293. package/templates/turborepo/.github/ISSUE_TEMPLATE/bug.yml +16 -1
  294. package/templates/turborepo/.github/dependabot.yml +1 -10
  295. package/templates/turborepo/.github/scripts/affected-apps.sh +5 -23
  296. package/templates/turborepo/.github/workflows/ci.yml +140 -103
  297. package/templates/turborepo/.github/workflows/deploy.yml +24 -23
  298. package/templates/turborepo/.github/workflows/sast.yml +0 -5
  299. package/templates/turborepo/AGENTS.md +21 -17
  300. package/templates/turborepo/CONTRIBUTING.md +6 -4
  301. package/templates/turborepo/README.md +8 -8
  302. package/templates/turborepo/REVIEW.md +21 -0
  303. package/templates/turborepo/apps/app/.dependency-cruiser.json +1 -1
  304. package/templates/turborepo/apps/app/AGENTS.md +9 -0
  305. package/templates/turborepo/apps/app/Dockerfile +2 -2
  306. package/templates/turborepo/apps/app/app/layout.tsx +2 -2
  307. package/templates/turborepo/apps/app/app/page.test.ts +1 -1
  308. package/templates/turborepo/apps/app/app/page.tsx +1 -1
  309. package/templates/turborepo/apps/app/package.json +3 -3
  310. package/templates/turborepo/apps/app/test/home.e2e.test.ts +3 -7
  311. package/templates/turborepo/apps/app/tsconfig.json +0 -3
  312. package/templates/turborepo/apps/backoffice/.dependency-cruiser.json +1 -1
  313. package/templates/turborepo/apps/backoffice/AGENTS.md +9 -0
  314. package/templates/turborepo/apps/backoffice/Dockerfile +2 -2
  315. package/templates/turborepo/apps/backoffice/package.json +4 -3
  316. package/templates/turborepo/apps/backoffice/src/app/(frontend)/layout.tsx +2 -2
  317. package/templates/turborepo/apps/backoffice/src/app/(frontend)/page.test.ts +1 -1
  318. package/templates/turborepo/apps/backoffice/src/app/(frontend)/page.tsx +1 -1
  319. package/templates/turborepo/apps/backoffice/src/app/favicon.ico +0 -0
  320. package/templates/turborepo/apps/backoffice/src/app/health/route.test.ts +20 -2
  321. package/templates/turborepo/apps/backoffice/src/app/health/route.ts +10 -2
  322. package/templates/turborepo/apps/backoffice/src/collections/media.test.ts +36 -1
  323. package/templates/turborepo/apps/backoffice/src/collections/media.ts +1 -1
  324. package/templates/turborepo/apps/backoffice/src/environment.test.ts +19 -0
  325. package/templates/turborepo/apps/backoffice/src/payload.config.test.ts +15 -0
  326. package/templates/turborepo/apps/backoffice/test/home.e2e.test.ts +3 -7
  327. package/templates/turborepo/apps/backoffice/tsconfig.json +0 -1
  328. package/templates/turborepo/knip.json +4 -2
  329. package/templates/turborepo/package.json +15 -15
  330. package/templates/turborepo/pnpm-workspace.yaml +1 -22
  331. package/templates/turborepo/turbo.json +32 -11
  332. package/templates/base/.agents/hooks/agent-suppression-gate.sh +0 -45
  333. package/templates/base/.claude/rules/agents-guide.md +0 -1
  334. package/templates/base/scripts/check-human-gates.ts +0 -383
  335. package/templates/base/scripts/check-intent.ts +0 -391
  336. package/templates/nextjs/knip.json +0 -4
  337. package/templates/payload/src/collections/users.test.ts +0 -10
  338. package/templates/turborepo/apps/backoffice/src/collections/users.test.ts +0 -10
@@ -0,0 +1,1186 @@
1
+ import {
2
+ cpSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import {execFileSync, spawnSync} from "node:child_process";
12
+ import {parse} from "yaml";
13
+ import path from "node:path";
14
+ import {tmpdir} from "node:os";
15
+
16
+ const checkKinds = [
17
+ "branch_matches",
18
+ "commit_touches_only",
19
+ "file_exists",
20
+ "file_matches",
21
+ "no_file_changes",
22
+ "no_new_branch",
23
+ "no_new_commits",
24
+ "reply_lacks",
25
+ "reply_matches",
26
+ ] as const;
27
+
28
+ const noArgumentKinds = new Set<CheckKind>(["no_file_changes", "no_new_branch", "no_new_commits"]);
29
+ const fixtureKinds = ["bare_remote", "branch", "checkout", "commit", "write"] as const;
30
+
31
+ type CheckKind = (typeof checkKinds)[number];
32
+ type FixtureKind = (typeof fixtureKinds)[number];
33
+
34
+ type Check = {
35
+ argument?: string;
36
+ kind: CheckKind;
37
+ regex?: string;
38
+ };
39
+
40
+ type FixtureStep = {
41
+ content?: string;
42
+ kind: FixtureKind;
43
+ value: string;
44
+ };
45
+
46
+ type EvalCase = {
47
+ checks: Check[];
48
+ fixture?: FixtureStep[];
49
+ hasProseFixture: boolean;
50
+ id: string;
51
+ prompt: string;
52
+ timeoutSeconds: number;
53
+ };
54
+
55
+ type Snapshot = {
56
+ branches: string[];
57
+ commits: string[];
58
+ head: string;
59
+ refs: string[];
60
+ status: string;
61
+ };
62
+
63
+ type Outcome = {
64
+ after: Snapshot;
65
+ before: Snapshot;
66
+ dir: string;
67
+ reply: string;
68
+ };
69
+
70
+ type AgentRun = {
71
+ costUsd?: number;
72
+ problem?: string;
73
+ reply: string;
74
+ };
75
+
76
+ type CaseResult = {
77
+ costUsd?: number;
78
+ failures: string[];
79
+ id: string;
80
+ seconds: number;
81
+ skipReason?: string;
82
+ total: number;
83
+ };
84
+
85
+ const evalsDirectory = path.join(".agents", "skill-evals");
86
+
87
+ const agentModel =
88
+ process.env.AGENT_EVALS_MODEL === undefined ? "claude-haiku-5-5" : process.env.AGENT_EVALS_MODEL;
89
+
90
+ const passRateThreshold = 1;
91
+ const regexFlags = "mu";
92
+ const globCharacters = /[*?[{]/;
93
+ const excludedNames = new Set([".git", "node_modules"]);
94
+
95
+ const allowedTools = [
96
+ "Bash(git:*)",
97
+ "Bash(pnpm:*)",
98
+ "Bash(node:*)",
99
+ "Read",
100
+ "Write",
101
+ "Edit",
102
+ "Glob",
103
+ "Grep",
104
+ "Skill",
105
+ ];
106
+
107
+ /**
108
+ * Run git in a directory.
109
+ * @param directory Working directory.
110
+ * @param gitArguments Arguments after `git`.
111
+ * @returns Trimmed standard output.
112
+ */
113
+ function runGit(directory: string, gitArguments: string[]): string {
114
+ return execFileSync("git", gitArguments, {
115
+ cwd: directory,
116
+ encoding: "utf8",
117
+ stdio: ["ignore", "pipe", "pipe"],
118
+ }).trim();
119
+ }
120
+
121
+ /**
122
+ * Split command output into lines.
123
+ * @param output Command output.
124
+ * @returns The non-empty lines.
125
+ */
126
+ function toLines(output: string): string[] {
127
+ return output.split("\n").filter(line => line !== "");
128
+ }
129
+
130
+ /**
131
+ * Commit every change in a directory.
132
+ * @param directory Git working directory.
133
+ * @param message Commit message.
134
+ */
135
+ function commitAll(directory: string, message: string): void {
136
+ runGit(directory, ["add", "--all"]);
137
+ runGit(directory, ["commit", "--message", message]);
138
+ }
139
+
140
+ /**
141
+ * Read what git knows about a directory.
142
+ * @param directory Git working directory.
143
+ * @returns Branches, commits, ref tips, the head commit and the porcelain status.
144
+ */
145
+ function takeSnapshot(directory: string): Snapshot {
146
+ return {
147
+ branches: toLines(runGit(directory, ["branch", "--format=%(refname:short)"])),
148
+ commits: toLines(runGit(directory, ["rev-list", "--all"])),
149
+ head: runGit(directory, ["rev-parse", "HEAD"]),
150
+ refs: [...new Set(toLines(runGit(directory, ["for-each-ref", "--format=%(objectname)"])))],
151
+ status: runGit(directory, ["status", "--porcelain", "--untracked-files=all"]),
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Turn one entry of a YAML list into a kind and its value.
157
+ * @param entry A string, or an object with one key.
158
+ * @param kinds The kinds the list accepts.
159
+ * @returns The kind and the value of its key, which is true for a bare string.
160
+ */
161
+ function splitEntry<Kind extends string>(
162
+ entry: unknown,
163
+ kinds: readonly Kind[],
164
+ ): {kind: Kind; value: unknown} {
165
+ const fields: Record<string, unknown> =
166
+ typeof entry === "object" && entry !== null
167
+ ? {
168
+ ...entry,
169
+ }
170
+ : {};
171
+
172
+ const [kind, value] =
173
+ typeof entry === "string" ? [entry, true] : (Object.entries(fields).at(0) ?? ["", undefined]);
174
+
175
+ const matched = kinds.find(candidate => candidate === kind);
176
+
177
+ if (matched === undefined) {
178
+ throw new Error(`unknown kind "${kind}"; use one of ${kinds.join(", ")}`);
179
+ }
180
+
181
+ return {
182
+ kind: matched,
183
+ value,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Read a field that must be a string.
189
+ * @param value Parsed YAML value.
190
+ * @param description What the field is, for the message.
191
+ * @returns The string.
192
+ */
193
+ function requireString(value: unknown, description: string): string {
194
+ if (typeof value !== "string") {
195
+ throw new TypeError(`${description} must be a string`);
196
+ }
197
+
198
+ return value;
199
+ }
200
+
201
+ /**
202
+ * Read a field that is an object of strings.
203
+ * @param value Parsed YAML value.
204
+ * @param description What the field is, for the message.
205
+ * @returns The object.
206
+ */
207
+ function requireStrings(value: unknown, description: string): Record<string, string> {
208
+ if (typeof value !== "object" || value === null) {
209
+ throw new TypeError(`${description} must be an object`);
210
+ }
211
+
212
+ return Object.fromEntries(
213
+ Object.entries(value).map(([key, field]) => [
214
+ key,
215
+ requireString(field, `${description}.${key}`),
216
+ ]),
217
+ );
218
+ }
219
+
220
+ /**
221
+ * Read one entry of `checks:`.
222
+ * @param entry A check kind, or an object with the kind as its only key.
223
+ * @returns The check.
224
+ */
225
+ function parseCheck(entry: unknown): Check {
226
+ const {kind, value} = splitEntry(entry, checkKinds);
227
+
228
+ if (kind === "file_matches") {
229
+ const fields = requireStrings(value, "file_matches");
230
+
231
+ return {
232
+ kind,
233
+ argument: requireString(fields.path, "file_matches.path"),
234
+ regex: requireString(fields.regex, "file_matches.regex"),
235
+ };
236
+ }
237
+
238
+ return value === true && noArgumentKinds.has(kind)
239
+ ? {
240
+ kind,
241
+ }
242
+ : {
243
+ kind,
244
+ argument: requireString(value, kind),
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Read one entry of `fixture:`.
250
+ * @param entry An object with the step kind as its only key.
251
+ * @returns The fixture step.
252
+ */
253
+ function parseFixtureStep(entry: unknown): FixtureStep {
254
+ const {kind, value} = splitEntry(entry, fixtureKinds);
255
+
256
+ if (kind === "write") {
257
+ const fields = requireStrings(value, "write");
258
+
259
+ return {
260
+ kind,
261
+ content: requireString(fields.content, "write.content"),
262
+ value: requireString(fields.path, "write.path"),
263
+ };
264
+ }
265
+
266
+ return {
267
+ kind,
268
+ value: requireString(value, kind),
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Read the text of `prompt.md` after its front matter.
274
+ * @param promptFile Content of `prompt.md`.
275
+ * @returns The prompt.
276
+ */
277
+ function readPromptBody(promptFile: string): string {
278
+ return promptFile.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
279
+ }
280
+
281
+ /**
282
+ * Load one case from its folder.
283
+ * @param casesDirectory The folder that holds the skill folders.
284
+ * @param skill Skill folder name.
285
+ * @param name Case folder name.
286
+ * @returns The case.
287
+ */
288
+ function loadCase(casesDirectory: string, skill: string, name: string): EvalCase {
289
+ const caseDirectory = path.join(casesDirectory, skill, name);
290
+ const description: unknown = parse(readFileSync(path.join(caseDirectory, "case.yaml"), "utf8"));
291
+
292
+ const fields: Record<string, unknown> =
293
+ typeof description === "object" && description !== null
294
+ ? {
295
+ ...description,
296
+ }
297
+ : {};
298
+
299
+ const timeoutSeconds = fields.timeout_seconds;
300
+ const {checks, fixture} = fields;
301
+
302
+ if (typeof timeoutSeconds !== "number") {
303
+ throw new TypeError("timeout_seconds must be a number");
304
+ }
305
+
306
+ if (!Array.isArray(checks) || checks.length === 0) {
307
+ throw new TypeError("checks must be a list with at least one check");
308
+ }
309
+
310
+ if (fixture !== undefined && !Array.isArray(fixture)) {
311
+ throw new TypeError("fixture must be a list of steps");
312
+ }
313
+
314
+ return {
315
+ timeoutSeconds,
316
+ checks: checks.map(entry => parseCheck(entry)),
317
+ fixture: fixture?.map(entry => parseFixtureStep(entry)),
318
+ hasProseFixture: existsSync(path.join(caseDirectory, "fixture.md")),
319
+ id: `${skill}/${name}`,
320
+ prompt: readPromptBody(readFileSync(path.join(caseDirectory, "prompt.md"), "utf8")),
321
+ };
322
+ }
323
+
324
+ /**
325
+ * List the case folders.
326
+ * @param casesDirectory The folder that holds the skill folders.
327
+ * @returns The `skill/name` of every folder that holds a `case.yaml`.
328
+ */
329
+ function listCaseIds(casesDirectory: string): string[] {
330
+ return readdirSync(casesDirectory, {
331
+ withFileTypes: true,
332
+ })
333
+ .filter(entry => entry.isDirectory())
334
+ .flatMap(skill =>
335
+ readdirSync(path.join(casesDirectory, skill.name), {
336
+ withFileTypes: true,
337
+ })
338
+ .filter(entry => entry.isDirectory())
339
+ .map(entry => `${skill.name}/${entry.name}`),
340
+ )
341
+ .toSorted((left, right) => left.localeCompare(right));
342
+ }
343
+
344
+ /**
345
+ * Choose the cases to run.
346
+ * @param caseIds Every `skill/name`.
347
+ * @param requested Names given on the command line; a bare name matches the case folder name.
348
+ * @returns The chosen ids, or every id when nothing was requested.
349
+ */
350
+ function selectCaseIds(caseIds: string[], requested: string[]): string[] {
351
+ const unknown = requested.filter(name =>
352
+ caseIds.every(id => !(id === name || id.endsWith(`/${name}`))),
353
+ );
354
+
355
+ if (unknown.length > 0) {
356
+ throw new Error(`no case named ${unknown.join(", ")}; the cases are:\n${caseIds.join("\n")}`);
357
+ }
358
+
359
+ return requested.length === 0
360
+ ? caseIds
361
+ : caseIds.filter(id => requested.some(name => id === name || id.endsWith(`/${name}`)));
362
+ }
363
+
364
+ /**
365
+ * Create a bare repository, add it as a remote, and push every branch to it.
366
+ * @param directory Git working directory.
367
+ * @param remoteName Name of the remote.
368
+ */
369
+ function addBareRemote(directory: string, remoteName: string): void {
370
+ const remotePath = path.join(path.dirname(directory), `${remoteName}.git`);
371
+
372
+ runGit(directory, ["init", "--bare", "--initial-branch=main", remotePath]);
373
+ runGit(directory, ["remote", "add", remoteName, remotePath]);
374
+ runGit(directory, ["push", remoteName, "--all"]);
375
+ }
376
+
377
+ /**
378
+ * Apply one fixture step to a repository.
379
+ * @param directory Git working directory.
380
+ * @param step The step.
381
+ */
382
+ function applyFixtureStep(directory: string, step: FixtureStep): void {
383
+ switch (step.kind) {
384
+ case "bare_remote": {
385
+ addBareRemote(directory, step.value);
386
+
387
+ return;
388
+ }
389
+
390
+ case "branch": {
391
+ runGit(directory, ["checkout", "-b", step.value]);
392
+
393
+ return;
394
+ }
395
+
396
+ case "checkout": {
397
+ runGit(directory, ["checkout", step.value]);
398
+
399
+ return;
400
+ }
401
+
402
+ case "commit": {
403
+ commitAll(directory, step.value);
404
+
405
+ return;
406
+ }
407
+
408
+ default: {
409
+ const filePath = path.join(directory, step.value);
410
+
411
+ mkdirSync(path.dirname(filePath), {
412
+ recursive: true,
413
+ });
414
+
415
+ writeFileSync(filePath, step.content ?? "");
416
+ }
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Apply fixture steps to a repository.
422
+ * @param directory Git working directory.
423
+ * @param steps The steps, in order.
424
+ */
425
+ function applyFixture(directory: string, steps: FixtureStep[]): void {
426
+ for (const step of steps) {
427
+ applyFixtureStep(directory, step);
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Create a git repository with one commit in a directory.
433
+ * @param directory Directory that holds the files.
434
+ */
435
+ function initializeRepository(directory: string): void {
436
+ runGit(directory, ["init", "--initial-branch=main"]);
437
+ runGit(directory, ["config", "user.name", "agent-evals"]);
438
+ runGit(directory, ["config", "user.email", "agent-evals@localhost"]);
439
+ runGit(directory, ["config", "commit.gpgsign", "false"]);
440
+ commitAll(directory, "chore: initial commit");
441
+ }
442
+
443
+ /**
444
+ * Copy the project without its git data and dependencies.
445
+ * @param source The project root.
446
+ * @param destination The folder to copy into.
447
+ */
448
+ function copyProject(source: string, destination: string): void {
449
+ cpSync(source, destination, {
450
+ recursive: true,
451
+ filter: sourcePath => !excludedNames.has(path.basename(sourcePath)),
452
+ });
453
+ }
454
+
455
+ /**
456
+ * List the files a run created or changed in the working tree.
457
+ * @param outcome What the run left behind.
458
+ * @returns Paths relative to the project root that exist now.
459
+ */
460
+ function listChangedFiles(outcome: Outcome): string[] {
461
+ const changed = [
462
+ ...toLines(runGit(outcome.dir, ["diff", "--name-only", "--no-renames", outcome.before.head])),
463
+ ...toLines(runGit(outcome.dir, ["ls-files", "--others", "--exclude-standard"])),
464
+ ];
465
+
466
+ return [...new Set(changed)].filter(file => existsSync(path.join(outcome.dir, file)));
467
+ }
468
+
469
+ /**
470
+ * Find the files a `path` designates.
471
+ * @param outcome What the run left behind.
472
+ * @param pattern A file path, or a glob that only the files the run created or changed can match.
473
+ * @returns Paths relative to the project root.
474
+ */
475
+ function findFiles(outcome: Outcome, pattern: string): string[] {
476
+ if (!globCharacters.test(pattern)) {
477
+ return existsSync(path.join(outcome.dir, pattern)) ? [pattern] : [];
478
+ }
479
+
480
+ return listChangedFiles(outcome).filter(file => path.matchesGlob(file, pattern));
481
+ }
482
+
483
+ /**
484
+ * Read the files of the first commit the run made.
485
+ * @param outcome What the run left behind.
486
+ * @returns The paths of that commit, or undefined when the run made no commit.
487
+ */
488
+ function readFirstNewCommitFiles(outcome: Outcome): string[] | undefined {
489
+ const firstCommit = toLines(
490
+ runGit(outcome.dir, [
491
+ "rev-list",
492
+ "--reverse",
493
+ "--topo-order",
494
+ "--all",
495
+ "--not",
496
+ ...outcome.before.refs,
497
+ ]),
498
+ ).at(0);
499
+
500
+ return firstCommit === undefined
501
+ ? undefined
502
+ : toLines(
503
+ runGit(outcome.dir, [
504
+ "diff-tree",
505
+ "--root",
506
+ "--no-commit-id",
507
+ "--name-only",
508
+ "-r",
509
+ firstCommit,
510
+ ]),
511
+ );
512
+ }
513
+
514
+ /**
515
+ * Compile a regular expression from a case file.
516
+ * @param source The pattern.
517
+ * @returns The expression, with the `m` and `u` flags.
518
+ */
519
+ function compilePattern(source: string): RegExp {
520
+ return new RegExp(source, regexFlags);
521
+ }
522
+
523
+ /**
524
+ * Tell whether a check passed, as a failure message or nothing.
525
+ * @param isPassing Whether the check passed.
526
+ * @param failure The message when it did not.
527
+ * @returns The message, or undefined when the check passed.
528
+ */
529
+ function failUnless(isPassing: boolean, failure: string): string | undefined {
530
+ return isPassing ? undefined : failure;
531
+ }
532
+
533
+ /**
534
+ * Find the new branches in a run.
535
+ * @param outcome What the run left behind.
536
+ * @returns Local branches that the fixture did not create.
537
+ */
538
+ function listNewBranches(outcome: Outcome): string[] {
539
+ return outcome.after.branches.filter(branch => !outcome.before.branches.includes(branch));
540
+ }
541
+
542
+ /**
543
+ * Find the new commits in a run.
544
+ * @param outcome What the run left behind.
545
+ * @returns Commits reachable from any ref that the fixture did not create.
546
+ */
547
+ function listNewCommits(outcome: Outcome): string[] {
548
+ return outcome.after.commits.filter(commit => !outcome.before.commits.includes(commit));
549
+ }
550
+
551
+ /**
552
+ * Check the name of the current branch.
553
+ * @param check A `branch_matches` check.
554
+ * @param outcome What the run left behind.
555
+ * @returns Why the check failed, or undefined.
556
+ */
557
+ function checkBranchMatches(check: Check, outcome: Outcome): string | undefined {
558
+ const branch = runGit(outcome.dir, ["branch", "--show-current"]);
559
+
560
+ return failUnless(
561
+ compilePattern(check.argument ?? "").test(branch),
562
+ `branch "${branch}" does not match ${check.argument}`,
563
+ );
564
+ }
565
+
566
+ /**
567
+ * Check the files of the first commit of the run.
568
+ * @param check A `commit_touches_only` check.
569
+ * @param outcome What the run left behind.
570
+ * @returns Why the check failed, or undefined.
571
+ */
572
+ function checkCommitTouchesOnly(check: Check, outcome: Outcome): string | undefined {
573
+ const files = readFirstNewCommitFiles(outcome);
574
+ const stray = (files ?? []).filter(file => !path.matchesGlob(file, check.argument ?? ""));
575
+
576
+ return failUnless(
577
+ files !== undefined && stray.length === 0,
578
+ files === undefined
579
+ ? "the run made no commit"
580
+ : `the first new commit also touches ${stray.join(", ")}, outside ${check.argument}`,
581
+ );
582
+ }
583
+
584
+ /**
585
+ * Check that a file exists.
586
+ * @param check A `file_exists` check.
587
+ * @param outcome What the run left behind.
588
+ * @returns Why the check failed, or undefined.
589
+ */
590
+ function checkFileExists(check: Check, outcome: Outcome): string | undefined {
591
+ return failUnless(
592
+ findFiles(outcome, check.argument ?? "").length > 0,
593
+ `no file ${check.argument}`,
594
+ );
595
+ }
596
+
597
+ /**
598
+ * Check the content of a file.
599
+ * @param check A `file_matches` check.
600
+ * @param outcome What the run left behind.
601
+ * @returns Why the check failed, or undefined.
602
+ */
603
+ function checkFileMatches(check: Check, outcome: Outcome): string | undefined {
604
+ const pattern = compilePattern(check.regex ?? "");
605
+
606
+ return failUnless(
607
+ findFiles(outcome, check.argument ?? "").some(file =>
608
+ pattern.test(readFileSync(path.join(outcome.dir, file), "utf8")),
609
+ ),
610
+ `no file ${check.argument} matches ${check.regex}`,
611
+ );
612
+ }
613
+
614
+ /**
615
+ * Check that the reply lacks a pattern.
616
+ * @param check A `reply_lacks` check.
617
+ * @param outcome What the run left behind.
618
+ * @returns Why the check failed, or undefined.
619
+ */
620
+ function checkReplyLacks(check: Check, outcome: Outcome): string | undefined {
621
+ const found = compilePattern(check.argument ?? "").exec(outcome.reply);
622
+
623
+ return failUnless(
624
+ found === null,
625
+ `the reply contains "${found?.at(0)}", matching ${check.argument}`,
626
+ );
627
+ }
628
+
629
+ /**
630
+ * Check that the working tree is as the fixture left it.
631
+ * @param _check A `no_file_changes` check.
632
+ * @param outcome What the run left behind.
633
+ * @returns Why the check failed, or undefined.
634
+ */
635
+ function checkNoFileChanges(_check: Check, outcome: Outcome): string | undefined {
636
+ return failUnless(
637
+ outcome.after.status === outcome.before.status,
638
+ `the working tree changed:\n${outcome.after.status}`,
639
+ );
640
+ }
641
+
642
+ /**
643
+ * Check that the run created no branch.
644
+ * @param _check A `no_new_branch` check.
645
+ * @param outcome What the run left behind.
646
+ * @returns Why the check failed, or undefined.
647
+ */
648
+ function checkNoNewBranch(_check: Check, outcome: Outcome): string | undefined {
649
+ return failUnless(
650
+ listNewBranches(outcome).length === 0,
651
+ `new branch ${listNewBranches(outcome).join(", ")}`,
652
+ );
653
+ }
654
+
655
+ /**
656
+ * Check that the run made no commit.
657
+ * @param _check A `no_new_commits` check.
658
+ * @param outcome What the run left behind.
659
+ * @returns Why the check failed, or undefined.
660
+ */
661
+ function checkNoNewCommits(_check: Check, outcome: Outcome): string | undefined {
662
+ return failUnless(
663
+ listNewCommits(outcome).length === 0,
664
+ `${listNewCommits(outcome).length} new commit(s)`,
665
+ );
666
+ }
667
+
668
+ /**
669
+ * Check that the reply matches a pattern.
670
+ * @param check A `reply_matches` check.
671
+ * @param outcome What the run left behind.
672
+ * @returns Why the check failed, or undefined.
673
+ */
674
+ function checkReplyMatches(check: Check, outcome: Outcome): string | undefined {
675
+ return failUnless(
676
+ compilePattern(check.argument ?? "").test(outcome.reply),
677
+ `the reply does not match ${check.argument}`,
678
+ );
679
+ }
680
+
681
+ const evaluators: Record<CheckKind, (check: Check, outcome: Outcome) => string | undefined> = {
682
+ branch_matches: checkBranchMatches,
683
+ commit_touches_only: checkCommitTouchesOnly,
684
+ file_exists: checkFileExists,
685
+ file_matches: checkFileMatches,
686
+ no_file_changes: checkNoFileChanges,
687
+ no_new_branch: checkNoNewBranch,
688
+ no_new_commits: checkNoNewCommits,
689
+ reply_lacks: checkReplyLacks,
690
+ reply_matches: checkReplyMatches,
691
+ };
692
+
693
+ type SelfTest = {
694
+ name: string;
695
+ passes: () => boolean;
696
+ };
697
+
698
+ type Expectation = [check: Check, isPassing: boolean, reply?: string];
699
+
700
+ /**
701
+ * Evaluate one check against what a run left behind.
702
+ * @param check The check.
703
+ * @param outcome What the run left behind.
704
+ * @returns Why the check failed, or undefined when it passed.
705
+ */
706
+ function findCheckFailure(check: Check, outcome: Outcome): string | undefined {
707
+ return evaluators[check.kind](check, outcome);
708
+ }
709
+
710
+ /**
711
+ * Describe a check in one line.
712
+ * @param check The check.
713
+ * @returns The kind and its argument.
714
+ */
715
+ function describeCheck(check: Check): string {
716
+ return [check.kind, check.argument, check.regex].filter(part => part !== undefined).join(" ");
717
+ }
718
+
719
+ /**
720
+ * Stop with a clear message when Claude Code cannot run here.
721
+ */
722
+ function assertClaudeReady(): void {
723
+ const version = spawnSync("claude", ["--version"], {
724
+ encoding: "utf8",
725
+ });
726
+
727
+ if (version.error !== undefined) {
728
+ throw new Error(
729
+ "`claude` is not installed or is not on PATH; install Claude Code and log in with `claude`",
730
+ );
731
+ }
732
+
733
+ const status = spawnSync("claude", ["auth", "status"], {
734
+ encoding: "utf8",
735
+ });
736
+
737
+ if (status.status !== 0 || /"loggedIn":\s*false/.test(status.stdout)) {
738
+ throw new Error(
739
+ "Claude Code is not logged in; run `claude` and log in, then run the evals again",
740
+ );
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Read the reply and the cost from `claude --output-format json`.
746
+ * @param stdout Standard output of the run.
747
+ * @returns The reply, the cost when reported, and a problem when the run reports an error.
748
+ */
749
+ function parseAgentOutput(stdout: string): AgentRun {
750
+ const report: unknown = JSON.parse(stdout);
751
+
752
+ const fields: Record<string, unknown> =
753
+ typeof report === "object" && report !== null
754
+ ? {
755
+ ...report,
756
+ }
757
+ : {};
758
+
759
+ const reply = typeof fields.result === "string" ? fields.result : "";
760
+ const costUsd = typeof fields.total_cost_usd === "number" ? fields.total_cost_usd : undefined;
761
+
762
+ return fields.is_error === true
763
+ ? {
764
+ costUsd,
765
+ reply,
766
+ problem: `Claude Code reported an error: ${reply}`,
767
+ }
768
+ : {
769
+ costUsd,
770
+ reply,
771
+ };
772
+ }
773
+
774
+ /**
775
+ * Run one prompt with Claude Code in a directory.
776
+ * @param directory Project copy to run in.
777
+ * @param prompt The prompt.
778
+ * @param timeoutSeconds How long the run may take.
779
+ * @returns The reply, the cost when reported, and a problem when the run did not finish.
780
+ */
781
+ function runAgent(directory: string, prompt: string, timeoutSeconds: number): AgentRun {
782
+ const run = spawnSync(
783
+ "claude",
784
+ [
785
+ "-p",
786
+ prompt,
787
+ "--model",
788
+ agentModel,
789
+ "--strict-mcp-config",
790
+ "--permission-mode",
791
+ "acceptEdits",
792
+ "--output-format",
793
+ "json",
794
+ "--allowedTools",
795
+ ...allowedTools,
796
+ ],
797
+ {
798
+ cwd: directory,
799
+ encoding: "utf8",
800
+ stdio: ["ignore", "pipe", "pipe"],
801
+ maxBuffer: 64 * 1024 * 1024,
802
+ timeout: timeoutSeconds * 1000,
803
+ },
804
+ );
805
+
806
+ if (run.error !== undefined) {
807
+ return {
808
+ reply: "",
809
+ problem: `claude did not finish within ${timeoutSeconds}s or failed to start: ${run.error.message}`,
810
+ };
811
+ }
812
+
813
+ try {
814
+ return parseAgentOutput(run.stdout);
815
+ } catch {
816
+ return {
817
+ reply: "",
818
+ problem: `claude exited with ${run.status} and no JSON report: ${run.stderr.trim()}`,
819
+ };
820
+ }
821
+ }
822
+
823
+ /**
824
+ * Run one case and evaluate its checks.
825
+ * @param evalCase The case.
826
+ * @param projectRoot The project to copy.
827
+ * @returns The result of the case.
828
+ */
829
+ function runCase(evalCase: EvalCase, projectRoot: string): CaseResult {
830
+ const started = Date.now();
831
+ const total = evalCase.checks.length;
832
+
833
+ if (evalCase.fixture === undefined && evalCase.hasProseFixture) {
834
+ return {
835
+ total,
836
+ skipReason: "fixture not machine-applicable",
837
+ seconds: 0,
838
+ failures: [],
839
+ id: evalCase.id,
840
+ };
841
+ }
842
+
843
+ const workspace = mkdtempSync(path.join(tmpdir(), "agent-eval-"));
844
+ const projectCopy = path.join(workspace, "project");
845
+
846
+ try {
847
+ copyProject(projectRoot, projectCopy);
848
+ initializeRepository(projectCopy);
849
+ applyFixture(projectCopy, evalCase.fixture ?? []);
850
+
851
+ const before = takeSnapshot(projectCopy);
852
+ const agent = runAgent(projectCopy, evalCase.prompt, evalCase.timeoutSeconds);
853
+
854
+ const outcome: Outcome = {
855
+ before,
856
+ dir: projectCopy,
857
+ after: takeSnapshot(projectCopy),
858
+ reply: agent.reply,
859
+ };
860
+
861
+ const failures =
862
+ agent.problem === undefined
863
+ ? evalCase.checks
864
+ .map(check => ({
865
+ check,
866
+ failure: findCheckFailure(check, outcome),
867
+ }))
868
+ .filter(({failure}) => failure !== undefined)
869
+ .map(({check, failure}) => `${describeCheck(check)}: ${failure}`)
870
+ : [agent.problem];
871
+
872
+ return {
873
+ failures,
874
+ total,
875
+ costUsd: agent.costUsd,
876
+ id: evalCase.id,
877
+ seconds: Math.round((Date.now() - started) / 1000),
878
+ };
879
+ } finally {
880
+ rmSync(workspace, {
881
+ force: true,
882
+ recursive: true,
883
+ });
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Format the result of one case as lines.
889
+ * @param result The result.
890
+ * @returns The first line states the verdict; failures follow, indented.
891
+ */
892
+ function formatResult(result: CaseResult): string[] {
893
+ if (result.skipReason !== undefined) {
894
+ return [`skipped ${result.id}: ${result.skipReason}`];
895
+ }
896
+
897
+ const cost = result.costUsd === undefined ? "" : `, $${result.costUsd.toFixed(4)}`;
898
+ const failedCount = result.failures.length;
899
+ const verdict = failedCount === 0 ? "passed" : "failed";
900
+
901
+ return [
902
+ `${verdict} ${result.id}: ${result.total - Math.min(failedCount, result.total)}/${result.total} checks, ${result.seconds}s${cost}`,
903
+ ...result.failures.map(failure => ` - ${failure}`),
904
+ ];
905
+ }
906
+
907
+ /**
908
+ * Build a check.
909
+ * @param kind The kind.
910
+ * @param argument The argument, when the kind takes one.
911
+ * @param regex The regular expression of `file_matches`.
912
+ * @returns The check.
913
+ */
914
+ function makeCheck(kind: CheckKind, argument?: string, regex?: string): Check {
915
+ return {
916
+ argument,
917
+ kind,
918
+ regex,
919
+ };
920
+ }
921
+
922
+ /**
923
+ * Evaluate expectations against an outcome.
924
+ * @param expectations Each check, whether it should pass, and the reply to judge it against.
925
+ * @param outcome What the run left behind.
926
+ * @returns True when every check behaves as expected.
927
+ */
928
+ function areExpectationsMet(expectations: Expectation[], outcome: Outcome): boolean {
929
+ return expectations.every(
930
+ ([check, isPassing, reply = ""]) =>
931
+ (findCheckFailure(check, {
932
+ ...outcome,
933
+ reply,
934
+ }) ===
935
+ undefined) ===
936
+ isPassing,
937
+ );
938
+ }
939
+
940
+ /**
941
+ * Tell whether parsing a check throws.
942
+ * @param entry A `checks:` entry.
943
+ * @returns True when the entry is rejected.
944
+ */
945
+ function isRejectedCheck(entry: unknown): boolean {
946
+ try {
947
+ parseCheck(entry);
948
+
949
+ return false;
950
+ } catch {
951
+ return true;
952
+ }
953
+ }
954
+
955
+ const thaiWord = String.raw`[\u0E00-\u0E7F]{5}`;
956
+ const numberedLines = "1. first\n2. second\n";
957
+
958
+ const replyExpectations: Expectation[] = [
959
+ [makeCheck("reply_matches", "^2\\. second$"), true, numberedLines],
960
+ [makeCheck("reply_matches", "^second$"), false, numberedLines],
961
+ [makeCheck("reply_matches", thaiWord), true, "สวัสดีครับ"],
962
+ [makeCheck("reply_matches", thaiWord), false, "hello"],
963
+ [makeCheck("reply_lacks", "/home/"), true, "src/a.ts"],
964
+ [makeCheck("reply_lacks", "/home/"), false, "see /home/me/a.ts"],
965
+ ];
966
+
967
+ const unchangedExpectations: Expectation[] = [
968
+ [makeCheck("no_file_changes"), true],
969
+ [makeCheck("no_new_commits"), true],
970
+ [makeCheck("no_new_branch"), true],
971
+ [makeCheck("branch_matches", "^main$"), true],
972
+ [makeCheck("commit_touches_only", "docs/**"), false],
973
+ [makeCheck("file_exists", "docs/*.md"), false],
974
+ [makeCheck("file_exists", "README.md"), true],
975
+ ];
976
+
977
+ const untrackedExpectations: Expectation[] = [
978
+ [makeCheck("no_file_changes"), false],
979
+ [makeCheck("no_new_commits"), true],
980
+ [makeCheck("file_exists", "*.txt"), true],
981
+ [makeCheck("file_exists", "*.csv"), false],
982
+ ];
983
+
984
+ const committedExpectations: Expectation[] = [
985
+ [makeCheck("no_new_commits"), false],
986
+ [makeCheck("no_new_branch"), false],
987
+ [makeCheck("no_file_changes"), true],
988
+ [makeCheck("branch_matches", "^feat/"), true],
989
+ [makeCheck("commit_touches_only", "intent.md"), true],
990
+ [makeCheck("commit_touches_only", "src.ts"), false],
991
+ [makeCheck("file_exists", "*.md"), true],
992
+ [makeCheck("file_matches", "*.md", "^Accepted by: @joe-dev\\.$"), true],
993
+ [makeCheck("file_matches", "*.md", "^Accepted by: @other\\.$"), false],
994
+ ];
995
+
996
+ const fixtureSteps: FixtureStep[] = [
997
+ {
998
+ content: "a\n",
999
+ kind: "write",
1000
+ value: "docs/a.md",
1001
+ },
1002
+ {
1003
+ kind: "commit",
1004
+ value: "docs: add a",
1005
+ },
1006
+ {
1007
+ kind: "branch",
1008
+ value: "feat/other",
1009
+ },
1010
+ {
1011
+ content: "b\n",
1012
+ kind: "write",
1013
+ value: "src/b.ts",
1014
+ },
1015
+ {
1016
+ kind: "commit",
1017
+ value: "feat: add b",
1018
+ },
1019
+ {
1020
+ kind: "checkout",
1021
+ value: "main",
1022
+ },
1023
+ {
1024
+ kind: "bare_remote",
1025
+ value: "origin",
1026
+ },
1027
+ ];
1028
+
1029
+ /**
1030
+ * Build the self-tests of the fixture applier and the check evaluators.
1031
+ * @param scratch An empty folder for the test repository.
1032
+ * @returns The tests, each already run; none calls a model.
1033
+ */
1034
+ function buildSelfTests(scratch: string): SelfTest[] {
1035
+ const project = path.join(scratch, "project");
1036
+
1037
+ mkdirSync(project);
1038
+ writeFileSync(path.join(project, "README.md"), "readme\n");
1039
+ initializeRepository(project);
1040
+ applyFixture(project, fixtureSteps);
1041
+
1042
+ const before = takeSnapshot(project);
1043
+
1044
+ const outcomeOf = (): Outcome => ({
1045
+ before,
1046
+ dir: project,
1047
+ reply: "",
1048
+ after: takeSnapshot(project),
1049
+ });
1050
+
1051
+ const isFixtureApplied =
1052
+ before.branches.join(",") === "feat/other,main" &&
1053
+ before.status === "" &&
1054
+ runGit(project, ["ls-remote", "--heads", "origin"]).includes("refs/heads/feat/other");
1055
+
1056
+ const isUnchanged = areExpectationsMet(
1057
+ [...unchangedExpectations, ...replyExpectations],
1058
+ outcomeOf(),
1059
+ );
1060
+
1061
+ writeFileSync(path.join(project, "notes.txt"), "scratch\n");
1062
+
1063
+ const isUntracked = areExpectationsMet(untrackedExpectations, outcomeOf());
1064
+
1065
+ rmSync(path.join(project, "notes.txt"));
1066
+ runGit(project, ["checkout", "-b", "feat/new"]);
1067
+ writeFileSync(path.join(project, "intent.md"), "Accepted by: @joe-dev.\n");
1068
+ commitAll(project, "docs: add intent");
1069
+ writeFileSync(path.join(project, "src.ts"), "x\n");
1070
+ commitAll(project, "feat: add src");
1071
+
1072
+ const isCommitted = areExpectationsMet(committedExpectations, outcomeOf());
1073
+
1074
+ return [
1075
+ {
1076
+ name: "the fixture creates branches, a clean tree and a remote that holds every branch",
1077
+ passes: () => isFixtureApplied,
1078
+ },
1079
+ {
1080
+ name: "a run that changed nothing passes the no_* checks, and the reply checks read line anchors and Thai",
1081
+ passes: () => isUnchanged,
1082
+ },
1083
+ {
1084
+ name: "an untracked file fails no_file_changes and a glob finds it",
1085
+ passes: () => isUntracked,
1086
+ },
1087
+ {
1088
+ name: "a new branch and commits fail the no_* checks and satisfy the commit and file checks",
1089
+ passes: () => isCommitted,
1090
+ },
1091
+ {
1092
+ name: "a check list rejects an unknown kind and a check without its fields",
1093
+ passes: () =>
1094
+ [
1095
+ isRejectedCheck("no_such_check"),
1096
+ isRejectedCheck({
1097
+ reply_matches: true,
1098
+ }),
1099
+ isRejectedCheck({
1100
+ file_matches: {
1101
+ path: "a",
1102
+ },
1103
+ }),
1104
+ !isRejectedCheck("no_file_changes"),
1105
+ !isRejectedCheck({
1106
+ file_matches: {
1107
+ path: "a",
1108
+ regex: "b",
1109
+ },
1110
+ }),
1111
+ ].every(Boolean),
1112
+ },
1113
+ {
1114
+ name: "prompt.md loses its front matter",
1115
+ passes: () => readPromptBody("---\ntags: [a]\n---\n\n/sdlc hello\n") === "/sdlc hello",
1116
+ },
1117
+ ];
1118
+ }
1119
+
1120
+ /**
1121
+ * Run the self-tests in a scratch folder.
1122
+ * @returns The names of the tests that failed.
1123
+ */
1124
+ function runSelfTests(): string[] {
1125
+ const scratch = mkdtempSync(path.join(tmpdir(), "agent-eval-selftest-"));
1126
+
1127
+ try {
1128
+ return buildSelfTests(scratch)
1129
+ .filter(test => !test.passes())
1130
+ .map(test => test.name);
1131
+ } finally {
1132
+ rmSync(scratch, {
1133
+ force: true,
1134
+ recursive: true,
1135
+ });
1136
+ }
1137
+ }
1138
+
1139
+ /**
1140
+ * Run the selected cases and report the pass rate.
1141
+ * @param requested Case names from the command line.
1142
+ */
1143
+ function runEvals(requested: string[]): void {
1144
+ const casesDirectory = path.join(process.cwd(), evalsDirectory);
1145
+ const caseIds = selectCaseIds(listCaseIds(casesDirectory), requested);
1146
+
1147
+ assertClaudeReady();
1148
+
1149
+ const results = caseIds.map(id => {
1150
+ const [skill = "", name = ""] = id.split("/", 2);
1151
+ const result = runCase(loadCase(casesDirectory, skill, name), process.cwd());
1152
+
1153
+ process.stdout.write(`${formatResult(result).join("\n")}\n`);
1154
+
1155
+ return result;
1156
+ });
1157
+
1158
+ const ran = results.filter(result => result.skipReason === undefined);
1159
+ const passed = ran.filter(result => result.failures.length === 0);
1160
+ const skipped = results.length - ran.length;
1161
+ const passRate = ran.length === 0 ? 0 : passed.length / ran.length;
1162
+
1163
+ process.stdout.write(
1164
+ `\nmodel ${agentModel}: ${passed.length} of ${ran.length} cases passed (${Math.round(passRate * 100)}%), ${skipped} skipped; the threshold is ${passRateThreshold * 100}%\n`,
1165
+ );
1166
+
1167
+ if (passRate < passRateThreshold) {
1168
+ process.exitCode = 1;
1169
+ }
1170
+ }
1171
+
1172
+ const selfTestFailures = runSelfTests();
1173
+
1174
+ if (selfTestFailures.length > 0) {
1175
+ const failureLines = selfTestFailures.map(name => `- ${name}`);
1176
+
1177
+ process.stderr.write(`Agent eval self-tests failed:\n${failureLines.join("\n")}\n`);
1178
+ process.exitCode = 1;
1179
+ } else {
1180
+ try {
1181
+ runEvals(process.argv.slice(2));
1182
+ } catch (error) {
1183
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
1184
+ process.exitCode = 1;
1185
+ }
1186
+ }