@polymorphism-tech/morph-spec 8.47.0 → 9.0.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 (222) hide show
  1. package/CLAUDE.md +48 -39
  2. package/README.md +3 -4
  3. package/bin/morph-spec.js +37 -51
  4. package/bin/validate.js +3 -111
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +41 -101
  9. package/framework/MORPH.md +95 -252
  10. package/framework/agents.json +75 -18
  11. package/framework/commands/morph-apply.md +5 -28
  12. package/framework/commands/morph-archive.md +1 -1
  13. package/framework/commands/morph-hotfix.md +8 -8
  14. package/framework/commands/morph-infra.md +1 -1
  15. package/framework/commands/morph-preflight.md +2 -3
  16. package/framework/commands/morph-proposal.md +2 -50
  17. package/framework/commands/morph-status.md +3 -3
  18. package/framework/docs/CLI.md +585 -286
  19. package/framework/evals/review-checklist.md +82 -41
  20. package/framework/hooks/README.md +10 -11
  21. package/framework/hooks/claude-code/core/comment-guard.js +1 -1
  22. package/framework/hooks/claude-code/core/denylist.json +33 -3
  23. package/framework/hooks/claude-code/core/enforce-phase-writes.js +16 -20
  24. package/framework/hooks/claude-code/core/gate-guard.js +2 -11
  25. package/framework/hooks/claude-code/core/pre-bash-denylist.js +38 -0
  26. package/framework/hooks/claude-code/core/protect-spec-files.js +30 -242
  27. package/framework/hooks/claude-code/core/state-sync.js +7 -21
  28. package/framework/hooks/claude-code/core/telemetry-log.js +3 -3
  29. package/framework/hooks/claude-code/core/validate-completion.js +33 -97
  30. package/framework/hooks/claude-code/core/warn-bash-source-write.js +31 -0
  31. package/framework/hooks/claude-code/core/worktree-context.js +5 -4
  32. package/framework/hooks/claude-code/statusline.py +55 -41
  33. package/framework/hooks/package.json +3 -0
  34. package/framework/hooks/shared/activity-logger.js +1 -1
  35. package/framework/hooks/shared/build-check.js +10 -6
  36. package/framework/hooks/shared/command-tokens.js +169 -0
  37. package/framework/hooks/shared/comment-rules.js +1 -1
  38. package/framework/hooks/shared/comment-scanner.js +10 -23
  39. package/framework/hooks/shared/comment-stripper.js +1 -1
  40. package/framework/hooks/shared/e2e-check.js +64 -27
  41. package/framework/hooks/shared/errata.js +72 -0
  42. package/framework/hooks/shared/feature-locator.js +1 -1
  43. package/framework/hooks/shared/frontend-locations.js +6 -0
  44. package/framework/hooks/shared/gate-question.js +18 -0
  45. package/framework/hooks/shared/phase-utils.js +15 -32
  46. package/framework/hooks/shared/slice-progress.js +118 -0
  47. package/framework/hooks/shared/spawn-command.js +23 -0
  48. package/framework/hooks/shared/state-reader.js +61 -87
  49. package/framework/hooks/shared/state-writer.js +2 -3
  50. package/framework/mcp/registry.json +3 -4
  51. package/framework/phases.json +23 -39
  52. package/framework/schemas/config.schema.json +14 -9
  53. package/framework/schemas/feature.schema.json +15 -50
  54. package/framework/skills/README.md +3 -4
  55. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  56. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  57. package/framework/skills/core/morph-apply/SKILL.md +149 -222
  58. package/framework/skills/core/morph-hotfix/SKILL.md +33 -42
  59. package/framework/skills/core/morph-machine/SKILL.md +2 -2
  60. package/framework/skills/core/morph-media/SKILL.md +1 -1
  61. package/framework/skills/core/morph-plan/SKILL.md +137 -299
  62. package/framework/skills/core/morph-review/SKILL.md +111 -136
  63. package/framework/skills/core/morph-standards/SKILL.md +27 -39
  64. package/framework/skills/core/morph-uiux/SKILL.md +77 -152
  65. package/framework/standards/STANDARDS.json +86 -1218
  66. package/framework/standards/STANDARDS.meta.json +1305 -0
  67. package/framework/standards/ai-agents/evals-with-cache.md +1 -2
  68. package/framework/standards/ai-agents/setup.md +1 -1
  69. package/framework/standards/architecture/code-comments.md +6 -6
  70. package/framework/standards/integration/mcp/mcp-tools.md +7 -7
  71. package/framework/templates/README.md +2 -3
  72. package/framework/templates/REGISTRY.json +6 -170
  73. package/framework/templates/docs/plan.md +72 -0
  74. package/framework/templates/dotnet/ai-kit/README.md +1 -9
  75. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +0 -10
  76. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -1
  77. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
  78. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +1 -1
  79. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +3 -4
  80. package/package.json +1 -1
  81. package/src/commands/e2e/e2e.js +2 -2
  82. package/src/commands/observability/cost.js +18 -60
  83. package/src/commands/project/doctor-procs.js +1 -1
  84. package/src/commands/project/doctor.js +9 -9
  85. package/src/commands/project/fleet.js +4 -4
  86. package/src/commands/project/heavy.js +3 -3
  87. package/src/commands/project/init.js +4 -0
  88. package/src/commands/project/setup-infra-cmd.js +2 -1
  89. package/src/commands/project/stack-scan.js +58 -0
  90. package/src/commands/project/standards.js +79 -25
  91. package/src/commands/project/status.js +29 -69
  92. package/src/commands/project/update.js +17 -1
  93. package/src/commands/project/worktree.js +1 -1
  94. package/src/commands/state/advance.js +62 -46
  95. package/src/commands/state/approve.js +81 -141
  96. package/src/commands/state/archive.js +20 -84
  97. package/src/commands/state/auto-approval.js +68 -0
  98. package/src/commands/state/create.js +28 -1
  99. package/src/commands/state/delete.js +1 -1
  100. package/src/commands/state/finish.js +11 -23
  101. package/src/commands/state/gate-check.js +85 -84
  102. package/src/commands/state/gate-decision.js +6 -4
  103. package/src/commands/state/handoff.js +1 -1
  104. package/src/commands/state/list.js +9 -9
  105. package/src/commands/state/report-check.js +157 -311
  106. package/src/commands/state/retype.js +9 -3
  107. package/src/commands/validation/mutate.js +77 -14
  108. package/src/commands/validation/plan-check.js +81 -0
  109. package/src/commands/validation/validate-feature.js +60 -135
  110. package/src/commands/validation/verify.js +40 -72
  111. package/src/core/paths/output-schema.js +19 -66
  112. package/src/core/state/gate-order.js +5 -6
  113. package/src/core/state/lifecycle-audit.js +2 -2
  114. package/src/core/state/state-manager.js +17 -98
  115. package/src/core/templates/template-registry.js +2 -2
  116. package/src/core/templates/template-seed.js +22 -0
  117. package/src/core/work-types.js +10 -10
  118. package/src/dashboard/app/components/ActivityFeed.jsx +2 -3
  119. package/src/dashboard/app/components/CommandPalette.jsx +5 -5
  120. package/src/dashboard/app/components/EntityLink.jsx +1 -1
  121. package/src/dashboard/app/hooks/useMorphQuery.js +1 -1
  122. package/src/dashboard/app/lib/phases.js +2 -4
  123. package/src/dashboard/app/pages/Activity.jsx +1 -1
  124. package/src/dashboard/app/pages/FeatureDetail.jsx +139 -129
  125. package/src/dashboard/app/pages/Features.jsx +9 -9
  126. package/src/dashboard/app/pages/Guide.jsx +2 -2
  127. package/src/dashboard/app/pages/PhasePipeline.jsx +3 -4
  128. package/src/dashboard/app/pages/Standards.jsx +1 -1
  129. package/src/dashboard/scanner/activity-scanner.js +5 -15
  130. package/src/dashboard/scanner/agents-scanner.js +1 -1
  131. package/src/dashboard/scanner/docs-scanner.js +2 -2
  132. package/src/dashboard/scanner/features-scanner.js +95 -60
  133. package/src/dashboard/scanner/project-api.js +1 -1
  134. package/src/dashboard/scanner/standards-scanner.js +2 -1
  135. package/src/lib/ci/ci-verify.js +17 -1
  136. package/src/lib/ci/github-ci.js +1 -1
  137. package/src/lib/detectors/index.js +3 -8
  138. package/src/lib/generators/handoff-generator.js +34 -65
  139. package/src/lib/generators/settings-generator.js +11 -89
  140. package/src/lib/git/evidence-staleness.js +1 -1
  141. package/src/lib/git/tree-fingerprint.js +19 -7
  142. package/src/lib/git/tree-state.js +2 -2
  143. package/src/lib/heavy/cleanup.js +1 -5
  144. package/src/lib/installers/memory-installer.js +6 -8
  145. package/src/lib/mutation/commands.js +3 -2
  146. package/src/lib/{tasks/dotnet-test-introspection.js → mutation/dotnet-introspection.js} +17 -282
  147. package/src/lib/mutation/plan.js +14 -6
  148. package/src/lib/mutation/runner.js +1 -1
  149. package/src/lib/mutation/subset.js +46 -9
  150. package/src/lib/plan/artifact-vocabulary.js +60 -0
  151. package/src/lib/plan/checks.js +234 -0
  152. package/src/lib/plan/plan-parser.js +209 -0
  153. package/src/lib/plan/plan-template.js +18 -0
  154. package/src/lib/review-reports.js +222 -0
  155. package/src/lib/risk-detector.js +131 -140
  156. package/src/lib/specs/adr-index.js +50 -69
  157. package/src/lib/specs/design-seed.js +18 -0
  158. package/src/lib/specs/frontmatter.js +44 -0
  159. package/src/lib/specs/gfm-table.js +17 -0
  160. package/src/lib/specs/old-format.js +68 -0
  161. package/src/lib/specs/revocations.js +37 -95
  162. package/src/lib/specs/screen-checklist.js +18 -17
  163. package/src/lib/stack/dotnet-build-ignores.js +47 -0
  164. package/src/lib/stack/stack-scan.js +267 -0
  165. package/src/lib/standards/registry.js +43 -0
  166. package/src/lib/standards/sections.js +58 -0
  167. package/src/lib/tasks/eval-node.js +4 -12
  168. package/src/lib/tasks/test-nodes.js +1 -74
  169. package/src/lib/tasks/test-runner.js +75 -117
  170. package/src/lib/telemetry/metrics.js +8 -69
  171. package/src/lib/telemetry/telemetry-reader.js +5 -118
  172. package/src/lib/telemetry/verify-event.js +5 -7
  173. package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
  174. package/src/lib/validators/contracts/contract-compliance-validator.js +2 -2
  175. package/src/lib/validators/reuse/reuse-validator.js +22 -45
  176. package/src/lib/validators/traceability-check.js +85 -129
  177. package/src/lib/validators/verify-runner.js +118 -235
  178. package/src/lib/worktree/docker-audit.js +1 -2
  179. package/src/lib/worktree/fleet-scanner.js +25 -38
  180. package/src/scripts/setup-infra.js +9 -0
  181. package/src/utils/agents-installer.js +76 -9
  182. package/src/utils/claude-settings-manager.js +0 -35
  183. package/src/utils/file-copier.js +27 -1
  184. package/src/utils/hooks-installer.js +16 -9
  185. package/src/utils/skills-installer.js +1 -2
  186. package/framework/evals/trace-schema.md +0 -67
  187. package/framework/hooks/claude-code/core/tasks-json-guard.js +0 -83
  188. package/framework/hooks/claude-code/core/trace-autogen.js +0 -277
  189. package/framework/hooks/git/pre-commit/specs.sh +0 -50
  190. package/framework/hooks/shared/tasks-validator.js +0 -111
  191. package/framework/rules/morph-task-tracking.md +0 -54
  192. package/framework/rules/morph-workflow.md +0 -244
  193. package/framework/schemas/tasks.schema.json +0 -79
  194. package/framework/skills/core/morph-brainstorming/SKILL.md +0 -161
  195. package/framework/skills/core/morph-implement/SKILL.md +0 -194
  196. package/framework/skills/core/morph-proposal/SKILL.md +0 -152
  197. package/framework/skills/core/morph-stack-scan/SKILL.md +0 -145
  198. package/framework/templates/docs/proposal.md +0 -192
  199. package/framework/templates/docs/spec.md +0 -57
  200. package/framework/templates/docs/ui-components.md +0 -52
  201. package/framework/templates/docs/ui-design-system.md +0 -76
  202. package/framework/templates/docs/ui-flows.md +0 -167
  203. package/framework/templates/examples/spec-examples.md +0 -91
  204. package/framework/templates/feature/decisions.md +0 -124
  205. package/framework/templates/feature/recap.md +0 -145
  206. package/framework/templates/tasks.template.json +0 -17
  207. package/src/commands/state/usecase.js +0 -143
  208. package/src/commands/tasks/dag.js +0 -181
  209. package/src/lib/agents/output-collector.js +0 -76
  210. package/src/lib/evaluator-report.js +0 -104
  211. package/src/lib/specs/acervo-format.js +0 -70
  212. package/src/lib/specs/acervo-lifecycle.js +0 -264
  213. package/src/lib/specs/done-criteria-check.js +0 -200
  214. package/src/lib/specs/usecase-check.js +0 -301
  215. package/src/lib/specs/usecase-promoter.js +0 -301
  216. package/src/lib/specs/usecase-registry.js +0 -237
  217. package/src/lib/tasks/dispatch-planner.js +0 -319
  218. package/src/lib/tasks/mermaid-renderer.js +0 -132
  219. package/src/lib/tasks/task-classifier.js +0 -30
  220. package/src/lib/tasks/task-parser.js +0 -209
  221. package/src/lib/tasks/tasks-json-parser.js +0 -296
  222. package/src/lib/tasks/test-scope.js +0 -597
@@ -22,10 +22,12 @@
22
22
 
23
23
  import { execFileSync } from 'child_process';
24
24
  import { createHash } from 'crypto';
25
- import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
25
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
26
26
  import { join } from 'path';
27
27
 
28
+ import { E2E_SPEC_LOCATIONS } from './frontend-locations.js';
28
29
  import { isSecondaryWorktree } from './git-paths.js';
30
+ import { resolveSpawn } from './spawn-command.js';
29
31
 
30
32
  /** Compose filenames Docker itself recognises, in the order it resolves them. */
31
33
  const COMPOSE_CANDIDATES = [
@@ -42,9 +44,6 @@ const PLAYWRIGHT_CONFIGS = [
42
44
  'playwright.config.mjs',
43
45
  ];
44
46
 
45
- /** Task groups that touch runtime and therefore deserve an e2e pass. */
46
- const RUNTIME_GROUPS = ['frontend', 'backend', 'infra'];
47
-
48
47
  /** How long to wait for every service to report healthy. */
49
48
  export const DEFAULT_HEALTH_TIMEOUT = 180_000;
50
49
 
@@ -71,9 +70,11 @@ const OUTPUT_TAIL_LINES = 20;
71
70
  * @returns {{ code: number, output: string }}
72
71
  */
73
72
  export function defaultExec(file, args, opts = {}) {
73
+ const spawn = resolveSpawn(file, args, { cwd: opts.cwd });
74
74
  try {
75
- const stdout = execFileSync(file, args, {
75
+ const stdout = execFileSync(spawn.file, spawn.args, {
76
76
  cwd: opts.cwd,
77
+ shell: spawn.shell,
77
78
  timeout: opts.timeout,
78
79
  encoding: 'utf-8',
79
80
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -125,8 +126,7 @@ export function resolveSpecCommand(cwd, config, frontendPath) {
125
126
  return { command, args, cwd };
126
127
  }
127
128
 
128
- const searchDirs = frontendPath ? [join(cwd, frontendPath), cwd] : [cwd];
129
- for (const dir of searchDirs) {
129
+ for (const dir of specSearchDirs(cwd, frontendPath)) {
130
130
  const hasConfig = PLAYWRIGHT_CONFIGS.some((name) => existsSync(join(dir, name)));
131
131
  // node_modules guard, same rationale as getBuildSteps(): a config file is
132
132
  // versioned but the toolchain is not, and `npx playwright` in a fresh
@@ -138,6 +138,49 @@ export function resolveSpecCommand(cwd, config, frontendPath) {
138
138
  return null;
139
139
  }
140
140
 
141
+ function specSearchDirs(cwd, frontendPath) {
142
+ return frontendPath ? [join(cwd, frontendPath), cwd] : E2E_SPEC_LOCATIONS.map((loc) => join(cwd, loc));
143
+ }
144
+
145
+ function hasPlaywrightConfig(dir) {
146
+ return PLAYWRIGHT_CONFIGS.some((name) => existsSync(join(dir, name)));
147
+ }
148
+
149
+ // Configs `resolveSpecCommand` did not run (searched dir without node_modules,
150
+ // or a first-level dir outside the search): each is a smoke pass hiding specs.
151
+ export function findUnrunPlaywrightConfigs(cwd, frontendPath) {
152
+ const searched = frontendPath ? [frontendPath, '.'] : [...E2E_SPEC_LOCATIONS];
153
+ const norm = (rel) => rel.replace(/\\/g, '/').replace(/^\.\/|\/$/g, '') || '.';
154
+ const searchedSet = new Set(searched.map(norm));
155
+ let firstLevel = [];
156
+ try {
157
+ firstLevel = readdirSync(cwd, { withFileTypes: true })
158
+ .filter((e) => e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.'))
159
+ .map((e) => e.name);
160
+ } catch { /* unreadable root: nothing to report */ }
161
+
162
+ const found = [];
163
+ const seen = new Set();
164
+ for (const rel of [...searched.map(norm), ...firstLevel]) {
165
+ if (seen.has(rel)) continue;
166
+ seen.add(rel);
167
+ const dir = join(cwd, rel);
168
+ if (!hasPlaywrightConfig(dir)) continue;
169
+ if (!searchedSet.has(rel)) found.push({ dir: rel, why: 'not-searched' });
170
+ else if (!existsSync(join(dir, 'node_modules'))) found.push({ dir: rel, why: 'no-node-modules' });
171
+ }
172
+ return found;
173
+ }
174
+
175
+ function unrunConfigWarning({ dir, why }) {
176
+ const where = dir === '.' ? 'na raiz' : `em ${dir}/`;
177
+ const cause = why === 'no-node-modules'
178
+ ? 'sem node_modules ao lado'
179
+ : 'fora de project.frontendPath e dos locais conhecidos';
180
+ const fix = dir === '.' ? 'instale as dependências' : `declare project.frontendPath: "${dir}" e instale as dependências`;
181
+ return `playwright.config.* ${where} não rodou (${cause}) — o nó e2e ficou só no smoke; ${fix}`;
182
+ }
183
+
141
184
  /**
142
185
  * Compose project name for a feature — the isolation key that lets two
143
186
  * worktrees run their stacks side by side without fighting over container
@@ -486,7 +529,7 @@ export function isDockerAvailable(cwd, exec = defaultExec) {
486
529
  // ============================================================================
487
530
 
488
531
  /**
489
- * Decide whether this scope deserves an e2e run, and if not, why.
532
+ * Decide whether this project deserves an e2e run, and if not, why.
490
533
  *
491
534
  * The load-bearing rule is the third one: a project that declares Docker in
492
535
  * `project.integrations` but has no compose file is NOT dispensed — it is a
@@ -494,13 +537,10 @@ export function isDockerAvailable(cwd, exec = defaultExec) {
494
537
  * would end up decorative (every project without a compose file collecting a
495
538
  * green Gate 3 forever).
496
539
  *
497
- * @param {{ taskGroup?: string|null, scope?: string, composeFile: string|null,
498
- * integrations?: string[], skipRequested?: boolean }} params
540
+ * @param {{ composeFile: string|null, integrations?: string[], skipRequested?: boolean }} params
499
541
  * @returns {{ run: boolean, status?: 'skip'|'fail', reason?: string }}
500
542
  */
501
543
  export function decideApplicability({
502
- taskGroup,
503
- scope = 'feature',
504
544
  composeFile,
505
545
  integrations = [],
506
546
  skipRequested = false,
@@ -509,13 +549,6 @@ export function decideApplicability({
509
549
  return { run: false, status: 'skip', reason: 'requested' };
510
550
  }
511
551
 
512
- // Task scope only runs for groups that touch runtime; feature scope (Gate 3)
513
- // always runs. An absent group is treated as runtime-touching — the safe
514
- // direction, since a missing field must never buy a free pass.
515
- if (scope === 'task' && taskGroup && !RUNTIME_GROUPS.includes(String(taskGroup).toLowerCase())) {
516
- return { run: false, status: 'skip', reason: `task group "${taskGroup}" does not touch runtime` };
517
- }
518
-
519
552
  if (!composeFile) {
520
553
  if (integrations.map((i) => String(i).toLowerCase()).includes('docker')) {
521
554
  return {
@@ -836,8 +869,6 @@ export function prepareAuth(
836
869
  * @param {object} [options]
837
870
  * @param {object} [options.config] - .morph/config/config.json contents
838
871
  * @param {string} [options.featureName] - drives COMPOSE_PROJECT_NAME
839
- * @param {string|null} [options.taskGroup] - task's `group`, for applicability
840
- * @param {string} [options.scope] - 'feature' | 'task'
841
872
  * @param {string} [options.gitCommonDir] - where the freshness stamp lives
842
873
  * @param {object} [options.env] - extra env (port block) for compose
843
874
  * @param {boolean} [options.skipRequested]
@@ -852,8 +883,6 @@ export function runE2eCheck(cwd, options = {}) {
852
883
  const {
853
884
  config,
854
885
  featureName = 'default',
855
- taskGroup = null,
856
- scope = 'feature',
857
886
  gitCommonDir = null,
858
887
  env,
859
888
  skipRequested = false,
@@ -863,6 +892,7 @@ export function runE2eCheck(cwd, options = {}) {
863
892
  healthTimeout = DEFAULT_HEALTH_TIMEOUT,
864
893
  specTimeout = DEFAULT_SPEC_TIMEOUT,
865
894
  beforeHeavy = null,
895
+ countSpecs = null,
866
896
  _exec: exec = defaultExec,
867
897
  } = options;
868
898
 
@@ -870,7 +900,7 @@ export function runE2eCheck(cwd, options = {}) {
870
900
  const composeFile = resolveComposeFile(cwd, config);
871
901
  const integrations = Array.isArray(config?.project?.integrations) ? config.project.integrations : [];
872
902
 
873
- const applicability = decideApplicability({ taskGroup, scope, composeFile, integrations, skipRequested });
903
+ const applicability = decideApplicability({ composeFile, integrations, skipRequested });
874
904
  if (!applicability.run) {
875
905
  return { status: applicability.status, command: null, output: '', reason: applicability.reason, warnings };
876
906
  }
@@ -936,7 +966,8 @@ export function runE2eCheck(cwd, options = {}) {
936
966
  if (auth.status === 'fail') {
937
967
  return { status: 'fail', command: null, output: '', reason: auth.reason, warnings };
938
968
  }
939
- return { ...runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv }, exec), warnings };
969
+ const specs = runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv, countSpecs }, exec);
970
+ return { ...specs, warnings: [...warnings, ...(specs.warnings || [])] };
940
971
  } finally {
941
972
  if (!keepUp) {
942
973
  tearDownStack(cwd, { composeFile, projectName, env: composeEnv }, exec);
@@ -952,8 +983,9 @@ export function runE2eCheck(cwd, options = {}) {
952
983
  * hardcoded localhost:3000 — ECONNREFUSED that looks exactly like a broken app.
953
984
  * `PLAYWRIGHT_BASE_URL` travels in that env for the config to read.
954
985
  */
955
- function runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv }, exec) {
956
- const spec = resolveSpecCommand(cwd, config, config?.project?.frontendPath);
986
+ function runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv, countSpecs }, exec) {
987
+ const frontendPath = config?.project?.frontendPath;
988
+ const spec = resolveSpecCommand(cwd, config, frontendPath);
957
989
  const notes = [up.reused ? up.reason : null, auth?.reason].filter(Boolean);
958
990
 
959
991
  if (!spec) {
@@ -962,11 +994,14 @@ function runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv
962
994
  command: `docker compose -f ${composeFile} up -d --wait`,
963
995
  output: '',
964
996
  reason: joinNotes('smoke only — stack healthy, no Playwright specs found', notes),
997
+ warnings: findUnrunPlaywrightConfigs(cwd, frontendPath).map(unrunConfigWarning),
965
998
  };
966
999
  }
967
1000
 
968
1001
  const commandLine = `${spec.command} ${spec.args.join(' ')}`;
969
1002
  const run = exec(spec.command, spec.args, { cwd: spec.cwd, timeout: specTimeout, env: composeEnv });
1003
+ // Read from the FULL output, before the tail cut; unparseable is null, never 0.
1004
+ const count = typeof countSpecs === 'function' ? (countSpecs(run.output) ?? null) : null;
970
1005
 
971
1006
  if (run.code !== 0) {
972
1007
  return {
@@ -974,6 +1009,7 @@ function runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv
974
1009
  command: commandLine,
975
1010
  output: tailLines(run.output, OUTPUT_TAIL_LINES),
976
1011
  reason: 'e2e specs failed against the running stack',
1012
+ count,
977
1013
  };
978
1014
  }
979
1015
 
@@ -982,6 +1018,7 @@ function runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv
982
1018
  command: commandLine,
983
1019
  output: '',
984
1020
  reason: notes.length ? notes.join('; ') : null,
1021
+ count,
985
1022
  };
986
1023
  }
987
1024
 
@@ -0,0 +1,72 @@
1
+ export const ERRATA_HEADING = '## Errata';
2
+
3
+ /** Strip BOM and CRLF — the one normalization every reader of a frozen markdown applies. */
4
+ export function normalizeMarkdown(text) {
5
+ return String(text ?? '').replace(/^/, '').replace(/\r\n?/g, '\n');
6
+ }
7
+
8
+ /**
9
+ * Lines with fenced blocks marked, so no heading is read inside ``` or ~~~.
10
+ *
11
+ * @returns {{ raw: string, n: number, fenced: boolean }[]}
12
+ */
13
+ export function markdownLines(text) {
14
+ const out = [];
15
+ let fence = null;
16
+ normalizeMarkdown(text).split('\n').forEach((raw, i) => {
17
+ const opener = /^\s*(`{3,}|~{3,})/.exec(raw);
18
+ let fenced = Boolean(fence);
19
+ if (fence) {
20
+ if (opener && opener[1][0] === fence[0] && opener[1].length >= fence.length) fence = null;
21
+ } else if (opener) {
22
+ fence = opener[1];
23
+ fenced = true;
24
+ }
25
+ out.push({ raw, n: i + 1, fenced });
26
+ });
27
+ return out;
28
+ }
29
+
30
+ const isErrataHeading = (line) => !line.fenced
31
+ && /^##\s+errata\s*$/i.test(line.raw);
32
+
33
+ /** Offset of the `## Errata` heading in the NORMALIZED text, or -1. */
34
+ export function errataOffset(text) {
35
+ let offset = 0;
36
+ for (const line of markdownLines(text)) {
37
+ if (isErrataHeading(line)) return offset;
38
+ offset += line.raw.length + 1;
39
+ }
40
+ return -1;
41
+ }
42
+
43
+ /** Everything before the errata block, normalized, without trailing blank. */
44
+ export function frozenBody(text) {
45
+ const norm = normalizeMarkdown(text);
46
+ const at = errataOffset(norm);
47
+ return (at === -1 ? norm : norm.slice(0, at)).trimEnd();
48
+ }
49
+
50
+ /**
51
+ * Is `proposed` the approved text plus an append-only errata? The body stays
52
+ * byte-identical, the result carries an errata block, and what the errata
53
+ * already said is never rewritten.
54
+ *
55
+ * @returns {{ok: true} | {ok: false, why: string}}
56
+ */
57
+ export function errataViolation(original, proposed) {
58
+ if (frozenBody(proposed) !== frozenBody(original)) {
59
+ return { ok: false, why: 'o corpo aprovado mudou — errata só ACRESCENTA ao fim, nunca altera o texto aprovado' };
60
+ }
61
+ const next = normalizeMarkdown(proposed);
62
+ const nextAt = errataOffset(next);
63
+ if (nextAt === -1) {
64
+ return { ok: false, why: `o conteúdo novo precisa estar sob um bloco \`${ERRATA_HEADING}\` no fim do arquivo` };
65
+ }
66
+ const prev = normalizeMarkdown(original);
67
+ const prevAt = errataOffset(prev);
68
+ if (prevAt !== -1 && !next.slice(nextAt).startsWith(prev.slice(prevAt).trimEnd())) {
69
+ return { ok: false, why: 'a errata existente foi reescrita — ela é append-only, entradas anteriores não se apagam' };
70
+ }
71
+ return { ok: true };
72
+ }
@@ -100,7 +100,7 @@ export function featureNamedIn(text, names) {
100
100
  * know" as opposed to "the text names nothing in particular".
101
101
  *
102
102
  * This is what the single-feature fallback below was missing: a gate question
103
- * for a feature not yet registered (no `create`, no `proposal.md` written
103
+ * for a feature not yet registered (no `create`, no `plan.md` written
104
104
  * yet — a fresh hotfix, say) names NO known feature either, and used to fall
105
105
  * through to "the tree's one feature must be it". That is exactly how a Gate 3
106
106
  * for `backfill-bool-filter` landed on `export-hub`, the tree's only
@@ -0,0 +1,6 @@
1
+ // The single list of frontend package locations: `src/lib/detectors` re-exports it,
2
+ // because a hook cannot import `src/`.
3
+ export const PACKAGE_LOCATIONS = ['.', 'src/frontend', 'src/web', 'frontend', 'apps/web', 'packages/app'];
4
+
5
+ // Where `verify`/`e2e` look for a Playwright setup when `project.frontendPath` is absent.
6
+ export const E2E_SPEC_LOCATIONS = [...PACKAGE_LOCATIONS, 'web'];
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The gate a gate question asks about, or null. Explicit gate names win over
3
+ * words that may also appear in a feature slug ("plan", "design", "review").
4
+ *
5
+ * @param {string} question
6
+ * @returns {'plan'|'uiux'|'review'|null}
7
+ */
8
+ export function gateFromQuestion(question) {
9
+ if (typeof question !== 'string') return null;
10
+ if (/gate\s+uiux/i.test(question)) return 'uiux';
11
+ if (/gate\s+3\b/i.test(question)) return 'review';
12
+ if (/gate\s+(?:[12]\b|d[oe]\s+plano|plan\b)/i.test(question)) return 'plan';
13
+ const lower = question.toLowerCase();
14
+ if (lower.includes('plano') || lower.includes('plan')) return 'plan';
15
+ if (lower.includes('design')) return 'uiux';
16
+ if (lower.includes('review') || lower.includes('ship')) return 'review';
17
+ return null;
18
+ }
@@ -8,13 +8,12 @@
8
8
  */
9
9
 
10
10
  /** Phase order */
11
- export const PHASE_ORDER = ["proposal","uiux","plan","implement","review","sync"];
11
+ export const PHASE_ORDER = ["plan","uiux","implement","review","sync"];
12
12
 
13
13
  /** Map phase → allowed output subdirectory */
14
14
  export const PHASE_DIRS = {
15
- proposal: '0-proposal',
16
- uiux: '1-design',
17
15
  plan: '2-plan',
16
+ uiux: '1-design',
18
17
  implement: '3-implement',
19
18
  review: '4-review',
20
19
  sync: '3-implement',
@@ -22,57 +21,41 @@ export const PHASE_DIRS = {
22
21
 
23
22
  /** Map output type (camelCase) → phase that produces it */
24
23
  export const OUTPUT_PHASE_MAP = {
25
- proposal: 'proposal',
26
- stackScan: 'proposal',
24
+ plan: 'plan',
25
+ checks: 'plan',
27
26
  uiDesignSystem: 'uiux',
28
27
  uiMockups: 'uiux',
29
28
  uiComponents: 'uiux',
30
29
  uiFlows: 'uiux',
31
- spec: 'plan',
32
- tasks: 'plan',
33
- decisions: 'plan',
34
- usecases: 'plan',
35
- recap: 'implement',
36
30
  evidencias: 'implement',
37
- reviewReport: 'review',
38
- evaluatorReport: 'review',
31
+ verifierReport: 'review',
32
+ reviewerReport: 'review',
33
+ securityReport: 'review',
39
34
  };
40
35
 
41
36
  /** Map filename → output type (camelCase) */
42
37
  export const FILENAME_TO_OUTPUT = {
43
- 'proposal.md': 'proposal',
44
- 'stack-scan.md': 'stackScan',
38
+ 'plan.md': 'plan',
39
+ 'checks.json': 'checks',
45
40
  'design-system.md': 'uiDesignSystem',
46
41
  'mockups.md': 'uiMockups',
47
42
  'components.md': 'uiComponents',
48
43
  'flows.md': 'uiFlows',
49
- 'spec.md': 'spec',
50
- 'tasks.json': 'tasks',
51
- 'decisions.md': 'decisions',
52
- 'recap.md': 'recap',
53
- 'review-report.md': 'reviewReport',
54
- 'evaluator-report.md': 'evaluatorReport',
44
+ 'verifier.md': 'verifierReport',
45
+ 'reviewer.md': 'reviewerReport',
46
+ 'security.md': 'securityReport',
55
47
  };
56
48
 
57
49
  /** Protected spec files and the approval gate that locks them */
58
50
  export const PROTECTED_SPEC_FILES = {
51
+ 'plan.md': 'plan',
52
+ 'checks.json': 'plan',
59
53
  'design-system.md': 'uiux',
60
54
  'mockups.md': 'uiux',
61
55
  'components.md': 'uiux',
62
56
  'flows.md': 'uiux',
63
- 'spec.md': 'plan',
64
- 'tasks.json': 'plan',
65
57
  };
66
58
 
67
- /**
68
- * For protected files that carry mutable progress fields: filename → the
69
- * top-level array-item fields that may change after the gate is approved.
70
- * When a protected file is listed here, the protect-spec-files hook does a
71
- * semantic diff (allowing only these fields to change) instead of a blanket block.
72
- */
73
- export const MUTABLE_SPEC_FIELDS = {
74
- 'tasks.json': ['status', 'outputs', 'notes'],
75
- };
76
59
 
77
60
  /**
78
61
  * Extract feature name from a .morph/features/{feature}/... path
@@ -88,7 +71,7 @@ export function extractFeatureName(filePath) {
88
71
  /**
89
72
  * Extract phase subdirectory from a .morph/features/{feature}/{phaseDir}/... path
90
73
  * @param {string} filePath - File path to analyze
91
- * @returns {string|null} Phase directory (e.g., '0-proposal', '2-design') or null
74
+ * @returns {string|null} Phase directory (e.g., '2-plan', '1-design') or null
92
75
  */
93
76
  export function extractPhaseDir(filePath) {
94
77
  const normalized = filePath.replace(/\\/g, '/');
@@ -0,0 +1,118 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ /** `PARCIAL` on the report's first non-blank line, tolerating heading/bold/quote typography. */
5
+ export const PARTIAL_MARKER = /^[\s#>*_]*PARCIAL\b/;
6
+
7
+ export const SLICE_STATUSES = ['pendente', 'parcial', 'feito'];
8
+
9
+ export function checksPath(featureDir) {
10
+ return join(featureDir, '2-plan', 'checks.json');
11
+ }
12
+
13
+ export function sliceReportPath(featureDir, sliceId) {
14
+ return join(featureDir, '3-implement', 'reports', `${sliceId}.md`);
15
+ }
16
+
17
+ /** Status of a slice from its report text: no report = pendente, PARCIAL first line = parcial, else feito. */
18
+ export function sliceStatusFromReport(text) {
19
+ if (text == null) return 'pendente';
20
+ const first = String(text).split(/\r?\n/).find((l) => l.trim() !== '') ?? '';
21
+ return PARTIAL_MARKER.test(first) ? 'parcial' : 'feito';
22
+ }
23
+
24
+ /**
25
+ * Progress of a feature's slices.
26
+ *
27
+ * @param {string|null} checksText - the raw extracted checks file of the plan
28
+ * @param {(sliceId: string) => string|null} readReport - report text, null when absent
29
+ * @returns {{ total: number, done: number, partial: number, pending: number,
30
+ * slices: { id: string, title: string, depends: string[], covers: string[], status: string }[] } | null} null when that file is unreadable
31
+ */
32
+ export function sliceProgressFrom(checksText, readReport) {
33
+ let slices;
34
+ try {
35
+ slices = JSON.parse(checksText ?? '').slices;
36
+ } catch {
37
+ return null;
38
+ }
39
+ if (!Array.isArray(slices)) return null;
40
+ const out = slices
41
+ .filter((s) => s && typeof s.id === 'string')
42
+ .map((s) => ({
43
+ id: s.id,
44
+ title: s.title ?? '',
45
+ depends: Array.isArray(s.depends) ? s.depends : [],
46
+ covers: Array.isArray(s.covers) ? s.covers : [],
47
+ status: sliceStatusFromReport(readReport(s.id)),
48
+ }));
49
+ const count = (status) => out.filter((s) => s.status === status).length;
50
+ return { total: out.length, done: count('feito'), partial: count('parcial'), pending: count('pendente'), slices: out };
51
+ }
52
+
53
+ function readOrNull(path) {
54
+ try {
55
+ return existsSync(path) ? readFileSync(path, 'utf8') : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /** Slice progress of the feature directory on disk. */
62
+ export function sliceProgress(featureDir) {
63
+ return sliceProgressFrom(readOrNull(checksPath(featureDir)), (id) => readOrNull(sliceReportPath(featureDir, id)));
64
+ }
65
+
66
+ /** Unfinished slices whose dependencies are all done: what the next dispatch can take. */
67
+ export function readySlices(progress) {
68
+ if (!progress) return [];
69
+ const done = new Set(progress.slices.filter((s) => s.status === 'feito').map((s) => s.id));
70
+ return progress.slices.filter((s) => s.status !== 'feito' && s.depends.every((d) => done.has(d)));
71
+ }
72
+
73
+ /**
74
+ * What a slice report must carry, besides prose and its covered scenarios.
75
+ * Loose on purpose: a false "complete" costs a read, a false "incomplete"
76
+ * re-dispatches finished work.
77
+ */
78
+ export const REPORT_MARKERS = [
79
+ { id: 'files', re: /^\s*(?:[-*]\s*)?(?:\*\*|#+\s*)?arquivos\s*:?\**\s*:?/mi },
80
+ ];
81
+
82
+ const MIN_PROSE_CHARS = 80;
83
+
84
+ const scenarioRe = (id) => new RegExp(`(?<![\\w-])${id}(?![\\w-])`);
85
+
86
+ /**
87
+ * Inspect one report file: does it exist, which mandatory items it lacks,
88
+ * and whether it is partial.
89
+ *
90
+ * @param {string} path
91
+ * @param {{ covers?: string[] }} [slice] - every scenario the slice covers must be named
92
+ */
93
+ export function inspectReport(path, { covers = [] } = {}) {
94
+ const missingAll = ['what', 'files', ...covers];
95
+ if (!existsSync(path)) return { exists: false, complete: false, missing: missingAll };
96
+ let body = '';
97
+ try {
98
+ body = readFileSync(path, 'utf8');
99
+ } catch {
100
+ return { exists: true, complete: false, missing: missingAll, unreadable: true };
101
+ }
102
+
103
+ const missing = REPORT_MARKERS.filter((m) => !m.re.test(body)).map((m) => m.id);
104
+ missing.push(...covers.filter((id) => !scenarioRe(id).test(body)));
105
+ const prose = body
106
+ .split('\n')
107
+ .filter((l) => !/^\s*(#|[-*]\s|\*\*)/.test(l))
108
+ .join(' ')
109
+ .replace(/\s+/g, ' ')
110
+ .trim();
111
+ if (prose.length < MIN_PROSE_CHARS) missing.unshift('what');
112
+
113
+ const firstLine = body.split('\n').find((l) => l.trim() !== '') ?? '';
114
+ const partial = PARTIAL_MARKER.test(firstLine);
115
+ if (partial) missing.push('partial');
116
+
117
+ return { exists: true, complete: missing.length === 0, missing, partial };
118
+ }
@@ -0,0 +1,23 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const NEEDS_CMD_SHIM = /^(npm|npx|pnpm|yarn)$/;
5
+
6
+ /**
7
+ * On win32 `npx`/`npm`/… are `.cmd` shims: ENOENT without a shell, EINVAL as `.cmd`
8
+ * with `shell: false`. Playwright runs from its own JS entry, so it needs no shell.
9
+ */
10
+ export function resolveSpawn(file, args = [], {
11
+ cwd = process.cwd(),
12
+ platform = process.platform,
13
+ exists = existsSync,
14
+ execPath = process.execPath,
15
+ } = {}) {
16
+ if (platform !== 'win32' || !NEEDS_CMD_SHIM.test(file)) return { file, args, shell: false };
17
+
18
+ if (file === 'npx' && args[0] === 'playwright') {
19
+ const cli = join(cwd, 'node_modules', '@playwright', 'test', 'cli.js');
20
+ if (exists(cli)) return { file: execPath, args: [cli, ...args.slice(1)], shell: false };
21
+ }
22
+ return { file: `${file}.cmd`, args, shell: true };
23
+ }