@polymorphism-tech/morph-spec 9.7.0 → 10.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 (211) hide show
  1. package/CLAUDE.md +11 -11
  2. package/README.md +4 -4
  3. package/bin/morph-spec.js +41 -44
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +24 -30
  8. package/framework/MORPH.md +47 -123
  9. package/framework/agents.json +6 -374
  10. package/framework/commands/morph-apply.md +1 -1
  11. package/framework/commands/morph-archive.md +4 -3
  12. package/framework/commands/morph-hotfix.md +8 -30
  13. package/framework/commands/morph-infra.md +2 -2
  14. package/framework/commands/morph-media.md +10 -9
  15. package/framework/commands/morph-review.md +15 -0
  16. package/framework/commands/morph-status.md +1 -1
  17. package/framework/docs/CLI.md +100 -102
  18. package/framework/evals/review-checklist.md +46 -54
  19. package/framework/hooks/README.md +4 -4
  20. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  21. package/framework/hooks/claude-code/core/README.md +1 -1
  22. package/framework/hooks/claude-code/core/denylist.json +4 -4
  23. package/framework/hooks/claude-code/core/gate-guard.js +9 -0
  24. package/framework/hooks/claude-code/core/masked-exit.js +49 -0
  25. package/framework/hooks/claude-code/core/state-sync.js +4 -1
  26. package/framework/hooks/claude-code/core/telemetry-log.js +1 -2
  27. package/framework/hooks/claude-code/core/test-budget.js +10 -70
  28. package/framework/hooks/claude-code/core/validate-completion.js +11 -24
  29. package/framework/hooks/claude-code/core/worktree-context.js +5 -22
  30. package/framework/hooks/claude-code/core/worktree-create.js +6 -45
  31. package/framework/hooks/claude-code/core/worktree-env-inject.js +2 -10
  32. package/framework/hooks/shared/e2e-check.js +0 -0
  33. package/framework/hooks/shared/gate-event.js +2 -2
  34. package/framework/hooks/shared/graph-refresh.js +20 -3
  35. package/framework/hooks/shared/heavy-command.js +32 -1
  36. package/framework/hooks/shared/heavy-config.js +1 -1
  37. package/framework/hooks/shared/heavy-slots.js +41 -4
  38. package/framework/hooks/shared/next-step.js +63 -24
  39. package/framework/hooks/shared/phase-utils.js +6 -9
  40. package/framework/hooks/shared/report-progress.js +86 -0
  41. package/framework/hooks/shared/state-reader.js +3 -2
  42. package/framework/hooks/statusline.js +332 -0
  43. package/framework/phases.json +32 -10
  44. package/framework/rules/ai-agents-standards.md +9 -1
  45. package/framework/rules/csharp-standards.md +4 -0
  46. package/framework/rules/ef-standards.md +20 -0
  47. package/framework/rules/frontend-standards.md +1 -1
  48. package/framework/rules/implement-report.md +25 -0
  49. package/framework/rules/infrastructure-standards.md +2 -0
  50. package/framework/rules/nextjs-standards.md +7 -0
  51. package/framework/rules/plan-grammar.md +53 -0
  52. package/framework/rules/ui-design.md +27 -0
  53. package/framework/schemas/config.schema.json +12 -19
  54. package/framework/schemas/feature.schema.json +7 -1
  55. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  56. package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +1 -1
  57. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +2 -2
  58. package/framework/skills/core/morph-apply/SKILL.md +38 -166
  59. package/framework/skills/core/morph-apply/references/debugging.md +8 -0
  60. package/framework/skills/core/morph-apply/references/hotfix.md +42 -0
  61. package/framework/skills/core/morph-apply/references/machine.md +29 -0
  62. package/framework/skills/core/{morph-media/SKILL.md → morph-apply/references/media.md} +11 -74
  63. package/framework/skills/core/morph-apply/references/review.md +65 -0
  64. package/framework/skills/core/morph-plan/SKILL.md +42 -108
  65. package/framework/skills/core/morph-plan/references/critique.md +40 -0
  66. package/framework/skills/core/morph-plan/references/interview.md +25 -0
  67. package/framework/skills/core/morph-uiux/SKILL.md +47 -64
  68. package/framework/skills/core/morph-uiux/surfaces.json +32 -0
  69. package/framework/standards/STANDARDS.json +32 -5
  70. package/framework/standards/STANDARDS.meta.json +5 -1
  71. package/framework/standards/ai-agents/agent-session.md +2 -3
  72. package/framework/standards/ai-agents/context-providers.md +2 -2
  73. package/framework/standards/ai-agents/durable-workflows-hitl.md +2 -2
  74. package/framework/standards/ai-agents/providers/model-registry.md +2 -2
  75. package/framework/standards/ai-agents/rag-custom-pgvector.md +6 -6
  76. package/framework/standards/ai-agents/setup.md +1 -1
  77. package/framework/standards/ai-agents/tool-adapters.md +2 -2
  78. package/framework/standards/architecture/stack-baseline.md +47 -0
  79. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -4
  80. package/framework/standards/frontend/design-system/ai-image-generation.md +51 -12
  81. package/framework/standards/frontend/design-system/ai-video-generation.md +50 -4
  82. package/framework/standards/frontend/design-system/art-direction.md +2 -2
  83. package/framework/standards/frontend/scroll-driven/frame-scrub.md +12 -2
  84. package/framework/standards/frontend/scroll-driven/scroll-components.md +76 -4
  85. package/framework/templates/.idea/morph-templates.xml +92 -0
  86. package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
  87. package/framework/templates/IDE-SNIPPETS.md +262 -262
  88. package/framework/templates/REGISTRY.json +1 -2
  89. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  90. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  91. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  92. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  93. package/framework/templates/context/ARCHITECTURE.md +1 -1
  94. package/framework/templates/docs/plan.md +12 -12
  95. package/framework/templates/docs/ui-mockups.md +17 -105
  96. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  97. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  98. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  99. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  100. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  101. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  102. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  103. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  104. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  105. package/framework/templates/state.template.json +17 -17
  106. package/framework/templates/ui/design-system.css +226 -226
  107. package/package.json +1 -1
  108. package/src/commands/e2e/e2e.js +88 -23
  109. package/src/commands/graph/diff.js +74 -0
  110. package/src/commands/graph/status.js +1 -1
  111. package/src/commands/observability/cost.js +2 -4
  112. package/src/commands/observability/telemetry.js +1 -1
  113. package/src/commands/project/doctor.js +23 -39
  114. package/src/commands/project/fleet.js +5 -6
  115. package/src/commands/project/init.js +63 -36
  116. package/src/commands/project/stack-scan.js +3 -42
  117. package/src/commands/project/stack-set.js +26 -0
  118. package/src/commands/project/status.js +12 -13
  119. package/src/commands/project/update.js +39 -3
  120. package/src/commands/project/worktree.js +27 -16
  121. package/src/commands/state/advance.js +17 -17
  122. package/src/commands/state/approve.js +0 -6
  123. package/src/commands/state/archive.js +27 -1
  124. package/src/commands/state/auto-approval.js +1 -1
  125. package/src/commands/state/create.js +0 -5
  126. package/src/commands/state/finish.js +43 -1
  127. package/src/commands/state/gate-check.js +37 -30
  128. package/src/commands/state/gate-decision.js +3 -2
  129. package/src/commands/state/next.js +1 -0
  130. package/src/commands/state/recap.js +67 -0
  131. package/src/commands/state/report-check.js +39 -146
  132. package/src/commands/validation/plan-check.js +16 -11
  133. package/src/commands/validation/validate-feature.js +3 -3
  134. package/src/commands/validation/verify.js +6 -6
  135. package/src/core/paths/output-schema.js +3 -3
  136. package/src/core/state/state-manager.js +2 -4
  137. package/src/dashboard/app/components/CommandPalette.jsx +2 -5
  138. package/src/dashboard/app/components/GatePauses.jsx +116 -0
  139. package/src/dashboard/app/components/SessionMap.jsx +77 -0
  140. package/src/dashboard/app/main.jsx +13 -13
  141. package/src/dashboard/app/pages/Activity.jsx +4 -0
  142. package/src/dashboard/app/pages/AgentMap.jsx +1 -2
  143. package/src/dashboard/app/pages/FeatureDetail.jsx +129 -186
  144. package/src/dashboard/app/pages/Features.jsx +7 -7
  145. package/src/dashboard/app/pages/PhasePipeline.jsx +1 -1
  146. package/src/dashboard/app/pages/Portfolio.jsx +40 -5
  147. package/src/dashboard/scanner/activity-scanner.js +29 -0
  148. package/src/dashboard/scanner/agents-scanner.js +11 -14
  149. package/src/dashboard/scanner/features-scanner.js +45 -24
  150. package/src/dashboard/scanner/project-api.js +11 -2
  151. package/src/dashboard/scanner/skills-scanner.js +11 -1
  152. package/src/dashboard/vite.config.js +24 -24
  153. package/src/lib/e2e/applicability.js +3 -1
  154. package/src/lib/git/tree-state.js +1 -16
  155. package/src/lib/graph/diff-tree.js +127 -0
  156. package/src/lib/graph/graphify-runtime.js +12 -5
  157. package/src/lib/heavy/gate.js +22 -1
  158. package/src/lib/installers/memory-installer.js +2 -2
  159. package/src/lib/installers/rules-installer.js +57 -0
  160. package/src/lib/installers/stack-assets.js +17 -0
  161. package/src/lib/installers/stack-sync.js +219 -0
  162. package/src/lib/plan/checks.js +55 -110
  163. package/src/lib/plan/critique.js +28 -0
  164. package/src/lib/plan/media.js +42 -0
  165. package/src/lib/plan/plan-html.js +125 -0
  166. package/src/lib/plan/plan-parser.js +34 -45
  167. package/src/lib/recap/piper.js +36 -0
  168. package/src/lib/recap/recap.js +232 -0
  169. package/src/lib/review-reports.js +33 -7
  170. package/src/lib/risk-detector.js +90 -11
  171. package/src/lib/specs/screen-checklist.js +33 -48
  172. package/src/lib/stack/dotnet-build-ignores.js +8 -9
  173. package/src/lib/tasks/test-nodes.js +24 -6
  174. package/src/lib/tasks/test-runner.js +40 -1
  175. package/src/lib/telemetry/metrics.js +27 -29
  176. package/src/lib/validators/reuse/reuse-validator.js +1 -9
  177. package/src/lib/validators/verify-runner.js +50 -19
  178. package/src/lib/worktree/fleet-scanner.js +7 -20
  179. package/src/lib/worktree/worktree-audit.js +0 -37
  180. package/src/scripts/setup-infra.js +15 -50
  181. package/src/utils/agents-installer.js +27 -201
  182. package/src/utils/hooks-installer.js +11 -95
  183. package/framework/hooks/claude-code/core/warn-bash-source-write.js +0 -23
  184. package/framework/hooks/claude-code/statusline.py +0 -699
  185. package/framework/hooks/claude-code/statusline.sh +0 -11
  186. package/framework/hooks/shared/agent-reap.js +0 -74
  187. package/framework/hooks/shared/slice-progress.js +0 -110
  188. package/framework/skills/core/morph-hotfix/SKILL.md +0 -133
  189. package/framework/skills/core/morph-machine/SKILL.md +0 -46
  190. package/framework/skills/core/morph-review/SKILL.md +0 -129
  191. package/framework/skills/core/morph-standards/SKILL.md +0 -52
  192. package/framework/standards/PROVENANCE.json +0 -119
  193. package/src/commands/project/worktree-reap.js +0 -34
  194. package/src/commands/state/handoff.js +0 -65
  195. package/src/commands/validation/mutate.js +0 -263
  196. package/src/commands/validation/scout-check.js +0 -28
  197. package/src/lib/generators/handoff-generator.js +0 -172
  198. package/src/lib/mutation/adapters.js +0 -79
  199. package/src/lib/mutation/commands.js +0 -61
  200. package/src/lib/mutation/dotnet-introspection.js +0 -281
  201. package/src/lib/mutation/manifest.js +0 -144
  202. package/src/lib/mutation/plan.js +0 -44
  203. package/src/lib/mutation/report.js +0 -68
  204. package/src/lib/mutation/runner.js +0 -301
  205. package/src/lib/mutation/schemata.js +0 -60
  206. package/src/lib/mutation/snapshot.js +0 -65
  207. package/src/lib/mutation/subset.js +0 -109
  208. package/src/lib/mutation/trx.js +0 -50
  209. package/src/lib/scout/scout-check.js +0 -293
  210. package/src/lib/validators/traceability-check.js +0 -103
  211. package/src/lib/worktree/agent-reaper.js +0 -247
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, mkdirSync, readFileSync } from 'fs';
3
+ import { existsSync, mkdirSync } from 'fs';
4
4
  import { execFileSync } from 'child_process';
5
5
  import { dirname, join, resolve } from 'path';
6
- import { homedir } from 'os';
7
6
  import { stateExists } from '../../shared/state-reader.js';
8
7
  import { resolveGitCommonDir } from '../../shared/git-paths.js';
9
8
  import { runMorphSpec } from '../../shared/morph-cli.js';
@@ -34,43 +33,6 @@ export function slugify(name) {
34
33
  return slug || `wt-${Date.now().toString(36)}`;
35
34
  }
36
35
 
37
- function readJsonSafe(path) {
38
- try { return JSON.parse(readFileSync(path, 'utf8').replace(/^/, '')); } catch { return null; }
39
- }
40
-
41
- // `null` when unset, distinct from an explicit `'fresh'`, which overrides the feature-branch default.
42
- export function readBaseRefSetting(root, home = homedir()) {
43
- let value = null;
44
- for (const p of [
45
- join(home, '.claude', 'settings.json'),
46
- join(root, '.claude', 'settings.json'),
47
- join(root, '.claude', 'settings.local.json'),
48
- ]) {
49
- const s = readJsonSafe(p);
50
- const v = s?.worktree?.baseRef;
51
- if (typeof v === 'string' && v) value = v;
52
- }
53
- return value;
54
- }
55
-
56
- function headRef(cwd) {
57
- try {
58
- const name = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
59
- if (name && name !== 'HEAD') return name;
60
- return git(['rev-parse', 'HEAD'], cwd) || null;
61
- } catch {
62
- return null;
63
- }
64
- }
65
-
66
- // Unset → the `morph/<f>` branch `cwd` is on: a sub-agent tree born from the default branch lacks the feature's work.
67
- export function resolveBase(setting, cwd) {
68
- if (setting === 'head') return headRef(cwd);
69
- if (setting) return null;
70
- const current = headRef(cwd);
71
- return current && /^morph\/.+/.test(current) ? current : null;
72
- }
73
-
74
36
  function isKnownFeature(root, name) {
75
37
  if (existsSync(join(root, '.morph', 'features', name, 'feature.json'))) return true;
76
38
  try {
@@ -92,14 +54,14 @@ function defaultBranch(root) {
92
54
  return null;
93
55
  }
94
56
 
95
- function gitFallback(root, name, base) {
57
+ function gitFallback(root, name) {
96
58
  const branch = `morph-task/${name}`;
97
59
  const container = newWorktreesDir(root);
98
60
  const path = `${container}/${name}`;
99
61
  if (existsSync(path)) return { path };
100
62
  try { git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], root); }
101
63
  catch {
102
- const from = base || defaultBranch(root);
64
+ const from = defaultBranch(root);
103
65
  git(from ? ['branch', branch, from] : ['branch', branch], root);
104
66
  }
105
67
  mkdirSync(container, { recursive: true });
@@ -113,21 +75,20 @@ export async function run(payload) {
113
75
  if (!root) return { error: `worktree-create: "${start}" is not inside a git repository` };
114
76
 
115
77
  const name = slugify(payload?.name);
116
- const base = resolveBase(readBaseRefSetting(root), start);
117
78
 
118
79
  if (!stateExists(root)) {
119
- try { return gitFallback(root, name, base); }
80
+ try { return gitFallback(root, name); }
120
81
  catch (err) { return { error: `worktree-create: ${err?.message || err}` }; }
121
82
  }
122
83
 
123
84
  const args = isKnownFeature(root, name)
124
85
  ? ['worktree', 'setup', name, '--json']
125
- : ['worktree', 'provision', name, '--json', ...(base ? ['--base', base] : [])];
86
+ : ['worktree', 'provision', name, '--json'];
126
87
 
127
88
  const r = runMorphSpec(args, { cwd: root });
128
89
  if (r.unavailable) {
129
90
  process.stderr.write('worktree-create: morph-spec CLI not found — creating the tree with plain git under worktrees/ (no infra links, no port block). Run `morph-spec worktree link --all` inside it.\n');
130
- try { return gitFallback(root, name, base); }
91
+ try { return gitFallback(root, name); }
131
92
  catch (err) { return { error: `worktree-create: ${err?.message || err}` }; }
132
93
  }
133
94
  if (r.stderr?.trim()) process.stderr.write(r.stderr);
@@ -49,11 +49,7 @@ export async function run(payload) {
49
49
  // CLAUDE_PROJECT_DIR first: it is what the project hooks resolve `.morph/framework` against.
50
50
  const anchored = anchorSessionCommand(command, process.env.CLAUDE_PROJECT_DIR || payload?.cwd || '');
51
51
  if (anchored) {
52
- return updatedInputResult(
53
- { ...payload.tool_input, command: anchored.command },
54
- 'PreToolUse',
55
- `⚓ âncora da sessão exportada para o CLI: ${ANCHOR_KEY}=${anchored.anchor} — o finish/worktree remove adia o desmonte do worktree em que esta sessão está ancorada`,
56
- );
52
+ return updatedInputResult({ ...payload.tool_input, command: anchored.command }, 'PreToolUse');
57
53
  }
58
54
 
59
55
  const dir = payload?.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -63,11 +59,7 @@ export async function run(payload) {
63
59
  const rewritten = rewriteCommand(command, loadEnvFile(manifest));
64
60
  if (!rewritten) return null;
65
61
 
66
- return updatedInputResult(
67
- { ...payload.tool_input, command: rewritten.command },
68
- 'PreToolUse',
69
- `🔌 worktree env injetado (.morph/worktree.env): ${rewritten.injected.join(' ')}`,
70
- );
62
+ return updatedInputResult({ ...payload.tool_input, command: rewritten.command }, 'PreToolUse');
71
63
  } catch {
72
64
  return null;
73
65
  }
Binary file
@@ -9,10 +9,10 @@ export const GATE_STEPS = ['decision', 'approve', 'unapprove'];
9
9
  * The trail `cost --metrics` turns into "what did the human do with each pause":
10
10
  * `gateDecisions` keeps only the latest decision, so the history lives here.
11
11
  */
12
- export function recordGateEvent(projectPath, { feature, gate, step, pause, signal, source, approvedBy }) {
12
+ export function recordGateEvent(projectPath, { feature, gate, step, pause, signal, source, resolver, approvedBy }) {
13
13
  if (!existsSync(join(projectPath || process.cwd(), '.morph'))) return;
14
14
  const event = { event: GATE_EVENT, feature, gate, step };
15
- if (step === 'decision') Object.assign(event, { pause: pause === true, signal: signal ?? null, source: source ?? null });
15
+ if (step === 'decision') Object.assign(event, { pause: pause === true, signal: signal ?? null, source: source ?? null, ...(pause === true ? { resolver: resolver ?? 'human' } : {}) });
16
16
  else event.approvedBy = approvedBy ?? null;
17
17
  appendTelemetryEvent(event, projectPath);
18
18
  }
@@ -8,6 +8,7 @@ import {
8
8
  readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync,
9
9
  } from 'fs';
10
10
  import { dirname, join, resolve } from 'path';
11
+ import { homedir } from 'os';
11
12
  import { fileURLToPath } from 'url';
12
13
  import { readPrimaryHead, resolveGitCommonDir, workTreeTop } from './git-paths.js';
13
14
  import { findFirstOnPath, quoteForCmd } from './morph-cli.js';
@@ -20,6 +21,8 @@ import {
20
21
 
21
22
  export { primaryTree };
22
23
 
24
+ export const GRAPHIFY_PACKAGE = 'graphifyy';
25
+
23
26
  // Ficam fora de propósito: `.graphify_root` guarda o caminho absoluto da raiz, `cache/`
24
27
  // é indexado pelo mtime da origem, e excluir `.graphifyignore` esconderia o da raiz.
25
28
  const SEED_FILES = ['manifest.json', '.graphify_analysis.json', '.graphify_labels.json', 'graph.json'];
@@ -131,12 +134,26 @@ function appendRefreshLog(dir, entry) {
131
134
  } catch { /* log é diagnóstico, nunca bloqueia */ }
132
135
  }
133
136
 
137
+ // The tool venv's own executable, for when the shim on PATH is missing: `uv tool install` writes the shim
138
+ // under $HOME, so an install run with another HOME leaves the venv in place and the shim elsewhere.
139
+ function venvGraphify(env, platform) {
140
+ const win = platform === 'win32';
141
+ const home = (win ? env.USERPROFILE : env.HOME) || homedir();
142
+ const venvs = [
143
+ join(env.UV_TOOL_DIR || (win ? join(env.APPDATA || join(home, 'AppData', 'Roaming'), 'uv', 'tools')
144
+ : join(env.XDG_DATA_HOME || join(home, '.local', 'share'), 'uv', 'tools')), GRAPHIFY_PACKAGE),
145
+ join(env.PIPX_HOME || (win ? join(home, 'pipx') : join(home, '.local', 'pipx')), 'venvs', GRAPHIFY_PACKAGE),
146
+ ];
147
+ const file = win ? join('Scripts', 'graphify.exe') : join('bin', 'graphify');
148
+ return venvs.map((v) => join(v, file)).find((f) => existsSync(f)) ?? null;
149
+ }
150
+
134
151
  // No win32 o libuv só acha `.exe` sem shell; `.cmd`/`.bat` passa pelo shell.
135
- export function resolveGraphify(env = process.env) {
136
- const names = process.platform === 'win32'
152
+ export function resolveGraphify(env = process.env, platform = process.platform) {
153
+ const names = platform === 'win32'
137
154
  ? ['graphify.exe', 'graphify.cmd', 'graphify.bat']
138
155
  : ['graphify'];
139
- const file = findFirstOnPath(names, env);
156
+ const file = findFirstOnPath(names, env) ?? venvGraphify(env, platform);
140
157
  return file ? { file, shell: /\.(cmd|bat)$/i.test(file) } : null;
141
158
  }
142
159
 
@@ -45,6 +45,8 @@ function skipWrappers(tokens) {
45
45
  }
46
46
  }
47
47
 
48
+ // The package managers' "run in that directory" flags: `npm --prefix <dir> test` is still `npm test`.
49
+ const PM_DIR_FLAGS = new Set(['--prefix', '--cwd', '-C', '--dir']);
48
50
  const DOCKER_VALUE_FLAGS = new Set(['-f', '--file', '-p', '--project-name', '--profile', '--env-file', '--project-directory', '-H', '--host', '--context']);
49
51
 
50
52
  function firstArg(args, valueFlags = new Set()) {
@@ -59,7 +61,8 @@ function kindOfTokens(tokens) {
59
61
  const [head, ...args] = tokens;
60
62
  if (!head || args.some((a) => INFO_FLAGS.has(a))) return null;
61
63
  const exe = executable(head);
62
- const sub = firstArg(args, exe === 'docker' || exe === 'docker-compose' ? DOCKER_VALUE_FLAGS : undefined);
64
+ const sub = firstArg(args, exe === 'docker' || exe === 'docker-compose' ? DOCKER_VALUE_FLAGS
65
+ : PACKAGE_MANAGERS.has(exe) ? PM_DIR_FLAGS : undefined);
63
66
 
64
67
  if (exe === 'dotnet') return DOTNET_HEAVY.has(sub) ? `dotnet ${sub}` : null;
65
68
  if (exe === 'msbuild') return 'msbuild';
@@ -148,6 +151,34 @@ function testKindOfTokens(tokens) {
148
151
  return kind && TEST_LABEL.test(kind) ? kind : null;
149
152
  }
150
153
 
154
+ const DOTNET_FILTERS = /^(--filter|--tests|--treenode-filter|\/testcasefilter:)/i;
155
+ const NAME_FILTERS = /^(-t|-g|--grep|--testNamePattern|--test-name-pattern|--testPathPattern|--filter)(=|$)/;
156
+ // A bare word may be a flag's value (`--reporter dot`); only a path-shaped argument selects files.
157
+ const TEST_PATH = /[\\/]|\.(spec|test)\b|\.[cm]?[jt]sx?$/;
158
+ const PATH_VALUE_FLAGS = new Set(['-c', '--config', '--project', '--root', '--workspace', '--outputFile', '--reporter', ...PM_DIR_FLAGS]);
159
+
160
+ function isFilteredRun(kind, tokens) {
161
+ const runnerAt = executable(tokens[0]) === 'node'
162
+ ? tokens.findIndex((t, i) => i > 0 && executable(t).replace(/\.[cm]?js$/, '') === kind)
163
+ : 0;
164
+ const args = tokens.slice(runnerAt + 1);
165
+ if (kind.startsWith('dotnet')) return args.some((a) => DOTNET_FILTERS.test(a));
166
+ for (let i = 0; i < args.length; i++) {
167
+ if (PATH_VALUE_FLAGS.has(args[i])) { i++; continue; }
168
+ if (NAME_FILTERS.test(args[i]) || (!args[i].startsWith('-') && TEST_PATH.test(args[i]))) return true;
169
+ }
170
+ return false;
171
+ }
172
+
173
+ /** `{ kind, filtered }` for the first test run in `command`, `null` when there is none. */
174
+ export function testRunCall(command) {
175
+ for (const tokens of commandTokens(command)) {
176
+ const kind = testKindOfTokens(tokens);
177
+ if (kind) return { kind, filtered: isFilteredRun(kind, tokens) };
178
+ }
179
+ return null;
180
+ }
181
+
151
182
  export function testRunKind(command) {
152
183
  for (const tokens of commandTokens(command)) {
153
184
  const kind = testKindOfTokens(tokens);
@@ -12,7 +12,7 @@ export const HEAVY_DEFAULTS = Object.freeze({
12
12
  maxHoldMinutes: 90,
13
13
  shutdownBuildServer: true,
14
14
  disableNodeReuse: false,
15
- gateBash: false,
15
+ gateBash: true,
16
16
  });
17
17
 
18
18
  export const HEAVY_KINDS = Object.freeze(['unit', 'build', 'e2e']);
@@ -51,8 +51,14 @@ function liveHoldersPruningStale(dir, ctx) {
51
51
 
52
52
  const kindSuffix = (kind) => (kind ? ` (${kind})` : '');
53
53
 
54
+ // Two full verify suites never run together; a sub-agent filter or a build only takes a slot.
55
+ export const isVerifySuite = ({ label, node }) => label === 'verify' && node === 'tests';
56
+
57
+ const runningSuite = (holders, ownPath = null) => holders.find((h) => h.path !== ownPath && isVerifySuite(h));
58
+
54
59
  function blockedText(blocked, slots, now) {
55
60
  const holders = blocked.holders.map((h) => describeHolder(h, now)).join('; ');
61
+ if (blocked.reason === 'suite') return `aguardando o verify de outra feature terminar a suíte de testes (${describeHolder(blocked.holders[0], now)})`;
56
62
  if (blocked.reason === 'exclusive') return `aguardando limpeza do servidor de build (${describeHolder(blocked.exclusive, now)})`;
57
63
  if (blocked.reason === 'memory') {
58
64
  const cause = blocked.holders.length
@@ -65,13 +71,16 @@ function blockedText(blocked, slots, now) {
65
71
 
66
72
  function tryClaim(dir, opts, ctx) {
67
73
  const {
68
- slots, label, feature, node, cwd, minFreeMemGb, freeMem, kind, lease: leaseSpec,
74
+ slots, label, feature, node, cwd, minFreeMemGb, freeMem, kind, suite, lease: leaseSpec,
69
75
  } = opts;
70
76
  const guarded = withClaimMutex(dir, () => {
71
77
  const holders = liveHoldersPruningStale(dir, ctx);
72
78
  const exclusive = readExclusive(dir, ctx);
73
79
  if (exclusive) return { blocked: { reason: 'exclusive', holders, exclusive } };
80
+ // A full queue is the deeper reason: with no free slot, waiting out the other suite is not enough.
74
81
  if (holders.length >= slots) return { blocked: { reason: 'slots', holders } };
82
+ const running = suite ? runningSuite(holders) : null;
83
+ if (running) return { blocked: { reason: 'suite', holders: [running] } };
75
84
  const freeGb = freeMem() / GB;
76
85
  if (minFreeMemGb > 0 && freeGb < minFreeMemGb) return { blocked: { reason: 'memory', holders, freeGb, minFreeMemGb, kind } };
77
86
 
@@ -116,10 +125,18 @@ export function acquireHeavy(opts) {
116
125
  return { status: 'acquired', lease: null, waitedMs: 0, reentrant: true };
117
126
  }
118
127
 
128
+ return pollClaim(() => tryClaim(dir, claimOpts, ctx), {
129
+ ...opts, now, sleep, log, minFreeMemGb: claimOpts.minFreeMemGb,
130
+ });
131
+ }
132
+
133
+ function pollClaim(claim, {
134
+ slots, waitTimeoutMs, minFreeMemGb = 0, kind = null, now, sleep, log,
135
+ }) {
119
136
  const start = now();
120
137
  let lastReport = -Infinity;
121
138
  for (;;) {
122
- const attempt = tryClaim(dir, claimOpts, ctx);
139
+ const attempt = claim();
123
140
  if (attempt.lease) return { status: 'acquired', lease: attempt.lease, waitedMs: now() - start };
124
141
 
125
142
  const waited = now() - start;
@@ -130,8 +147,8 @@ export function acquireHeavy(opts) {
130
147
  reason: blocked.reason,
131
148
  holders: blocked.holders,
132
149
  freeGb: blocked.freeGb ?? null,
133
- minFreeMemGb: claimOpts.minFreeMemGb,
134
- kind: opts.kind ?? null,
150
+ minFreeMemGb,
151
+ kind,
135
152
  waitedMs: waited,
136
153
  };
137
154
  }
@@ -143,6 +160,25 @@ export function acquireHeavy(opts) {
143
160
  }
144
161
  }
145
162
 
163
+ // Keeps the slot held while it waits: releasing it would let a build or filter take the place.
164
+ export function enterVerifySuite(opts) {
165
+ const {
166
+ dir, lease, maxHoldMs,
167
+ now = Date.now, sleep = sleepSync, isAlive = pidAlive, log = (m) => process.stderr.write(`${m}\n`),
168
+ } = opts;
169
+ const ctx = { now, isAlive, maxHoldMs };
170
+ const claim = () => {
171
+ const guarded = withClaimMutex(dir, () => {
172
+ const running = runningSuite(scanHolders(dir, ctx).live, lease.path);
173
+ if (running) return { blocked: { reason: 'suite', holders: [running] } };
174
+ updateLease(lease, { node: 'tests' });
175
+ return { lease };
176
+ }, ctx);
177
+ return guarded ? guarded.value : { blocked: { reason: 'contended', holders: [] } };
178
+ };
179
+ return pollClaim(claim, { ...opts, now, sleep, log });
180
+ }
181
+
146
182
  // For a holder whose next node needs more RAM than it was admitted with: it keeps its slot and
147
183
  // waits only on memory, since re-claiming would hit the re-entrant path and skip the check.
148
184
  export function awaitFreeMemory(opts) {
@@ -206,6 +242,7 @@ export function describeBusy(result, config) {
206
242
  const key = result.kind ? `heavy.minFreeMemGbByKind.${result.kind}` : 'heavy.minFreeMemGb';
207
243
  return `sem RAM livre há ${waited}s (${result.freeGb?.toFixed(1)} GB < ${floor} GB mínimos${kindSuffix(result.kind)}, ${key})`;
208
244
  }
245
+ if (result.reason === 'suite') return `o verify de outra feature segue na suíte de testes após ${waited}s (heavy.waitTimeoutSec)`;
209
246
  if (result.reason === 'exclusive') return `limpeza do servidor de build ainda em curso após ${waited}s`;
210
247
  return `${result.holders.length}/${config.slots} slots pesados ocupados por ${waited}s (heavy.waitTimeoutSec)`;
211
248
  }
@@ -4,26 +4,71 @@
4
4
  import { existsSync, readFileSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import { derivePhaseForFeature } from './state-reader.js';
7
- import { sliceProgress, readySlices } from './slice-progress.js';
7
+ import { implementProgress } from './report-progress.js';
8
8
 
9
9
  export const FIX_ATTEMPTS = 3;
10
10
 
11
11
  const step = (action, command, reason, extra = {}) => ({ action, command, reason, ...extra });
12
12
 
13
+ const HOW = {
14
+ plan: (f) => [
15
+ `Rode \`/morph-plan ${f}\`: escreva 2-plan/plan.md e rode \`morph-spec plan-check ${f}\` até zero erro.`,
16
+ ],
17
+ gate: (f) => [
18
+ `Rode \`morph-spec gate-check ${f} plan\`; se pausar, AskUserQuestion "Gate do plano — Plano de ${f} aprovado?".`,
19
+ ],
20
+ design: (f) => [
21
+ `Rode \`/morph-uiux ${f}\` (opções em 1-design/opcoes/, protótipo e prints); depois \`morph-spec gate-check ${f} uiux\`.`,
22
+ `Se pausar, AskUserQuestion "Gate uiux — Design de ${f} aprovado?".`,
23
+ ],
24
+ implement: (f) => [
25
+ 'Implemente os Cn pendentes em sequência; ao fechar cada um, marque `feito` + evidência (teste ou arquivo:linha) '
26
+ + `em 3-implement/report.md e commite \`wip(${f}): Cn …\`.`,
27
+ ],
28
+ decide: () => [
29
+ 'Leia a pergunta do Cn bloqueado em 3-implement/report.md ## Notas; decida pelo plano/ADR ou faça AskUserQuestion com ela.',
30
+ 'Registre a resposta em ## Notas, volte o Cn para `pendente` e siga implementando.',
31
+ ],
32
+ verify: (f) => [
33
+ `\`morph-spec verify ${f} --json\` sem filtro; passou de 10 min, run_in_background.`,
34
+ ],
35
+ fix: (f) => [
36
+ `Leia o \`feedback\` do último verify, corrija a causa e rode \`morph-spec verify ${f} --json\` de novo, sem filtro.`,
37
+ ],
38
+ escalate: () => [
39
+ 'AskUserQuestion com o texto do reason (aceitar/continuar/parar) e siga a resposta; não rode outro verify antes dela.',
40
+ ],
41
+ review: () => [
42
+ 'Siga .claude/skills/morph-apply/references/review.md.',
43
+ ],
44
+ finish: (f) => [
45
+ `AskUserQuestion header "Integração" (PR ou merge); depois \`morph-spec finish ${f} --pr\` ou \`--merge\`.`,
46
+ ],
47
+ status: (f) => [
48
+ `Rode \`morph-spec status ${f}\` e confira a fase em .morph/features/${f}/feature.json.`,
49
+ ],
50
+ };
51
+
13
52
  /**
14
- * @param {{ feature: string, phase: string, slices: object|null, gates: object,
53
+ * @param {{ feature: string, phase: string, progress: object|null, gates: object,
15
54
  * verification: { status?: string }|null, verificationStale?: boolean, failStreak?: number }} s
16
55
  * `verificationStale` undefined = unknown (the hook has no git): read as fresh.
17
56
  */
18
57
  export function decideNext(s) {
58
+ const n = decide(s);
59
+ const how = (HOW[n.action] ?? HOW.status)(s.feature);
60
+ const hotfix = s.workType === 'hotfix' && n.command.startsWith('/morph-hotfix');
61
+ return { ...n, how: hotfix ? ['Fluxo de hotfix: siga .claude/skills/morph-apply/references/hotfix.md.', ...how] : how };
62
+ }
63
+
64
+ function decide(s) {
19
65
  const f = s.feature;
20
66
  const approved = (g) => s.gates?.[g]?.approved === true;
21
- // The hotfix pipeline (scout, human approval, ship) lives in its own skill, end to end.
22
67
  const skill = (name) => (s.workType === 'hotfix' ? `/morph-hotfix ${f}` : `/${name} ${f}`);
23
68
 
24
69
  switch (s.phase) {
25
70
  case 'plan':
26
- return s.slices
71
+ return s.progress
27
72
  ? step('gate', `morph-spec gate-check ${f} plan`, 'plano extraído: o gate do plano decide a pausa')
28
73
  : step('plan', `/morph-plan ${f}`, 'sem checks.json: escreva o plan.md e rode plan-check até zero erro');
29
74
 
@@ -31,26 +76,20 @@ export function decideNext(s) {
31
76
  return step('design', `morph-spec gate-check ${f} uiux`, 'complete 1-design/ (morph-uiux) e passe o gate de uiux');
32
77
 
33
78
  case 'implement': {
34
- if (!s.slices) return step('plan', skill('morph-plan'), 'sem checks.json: plan.md curto (uma fatia) e plan-check até zero erro');
35
- if (s.slices.done < s.slices.total) {
36
- const ready = readySlices(s.slices);
37
- if (!ready.length) {
38
- const waiting = s.slices.slices.filter((sl) => sl.status === 'bloqueado').map((sl) => sl.id);
39
- if (waiting.length) {
40
- return step('decide', 'AskUserQuestion (decisão da fatia)',
41
- 'relatório BLOQUEADO: decida pelo plano/ADR ou pergunte, e retome o MESMO agente com a resposta — nunca re-despache',
42
- { slices: waiting });
43
- }
44
- return step('blocked', `morph-spec report-check ${f}`, 'fatias pendentes sem dependência satisfeita');
45
- }
46
- const partial = ready.filter((sl) => sl.status === 'parcial').map((sl) => sl.id);
47
- return step('dispatch', skill('morph-apply'),
48
- partial.length ? `retome ${partial.join(', ')} (PARCIAL) e despache as prontas` : 'despache as fatias prontas',
49
- { slices: ready.map((sl) => sl.id) });
79
+ const p = s.progress;
80
+ if (!p) return step('plan', skill('morph-plan'), 'sem checks.json: plan.md curto e plan-check até zero erro');
81
+ const blocked = p.checks.filter((c) => c.status === 'bloqueado').map((c) => c.id);
82
+ if (blocked.length) {
83
+ return step('decide', 'AskUserQuestion (decisão do cenário)',
84
+ 'Cn bloqueado no report.md: decida pelo plano/ADR ou pergunte, e siga implementando', { checks: blocked });
85
+ }
86
+ const pending = p.checks.filter((c) => c.status === 'pendente').map((c) => c.id);
87
+ if (pending.length) {
88
+ return step('implement', skill('morph-apply'), 'implemente nesta sessão e marque cada Cn feito no report.md', { checks: pending });
50
89
  }
51
90
  const v = s.verification;
52
91
  if (!v?.status || s.verificationStale === true) {
53
- return step('verify', `morph-spec verify ${f}`, v?.status ? 'a árvore andou desde o último verify' : 'todas as fatias relatadas, sem verify');
92
+ return step('verify', `morph-spec verify ${f}`, v?.status ? 'a árvore andou desde o último verify' : 'todo Cn feito, sem verify');
54
93
  }
55
94
  if (v.status === 'fail') {
56
95
  const streak = s.failStreak ?? 0;
@@ -77,8 +116,8 @@ export function decideNext(s) {
77
116
  }
78
117
 
79
118
  export function formatNext(n) {
80
- const slices = n.slices?.length ? ` [${n.slices.join(', ')}]` : '';
81
- return `próximo: ${n.action}${slices} → ${n.command} — ${n.reason}`;
119
+ const ids = n.checks?.length ? ` [${n.checks.join(', ')}]` : '';
120
+ return `próximo: ${n.action}${ids} → ${n.command} — ${n.reason}`;
82
121
  }
83
122
 
84
123
  /** Facts readable from `hooks/shared`; the CLI adds `verificationStale` (git) on top. Null without feature.json. */
@@ -99,7 +138,7 @@ export function collectNextSignals(featureName, cwd) {
99
138
  workType: feature.workType ?? 'feature',
100
139
  worktreePath: feature.worktree?.path ?? null,
101
140
  phase,
102
- slices: sliceProgress(featureDir),
141
+ progress: implementProgress(featureDir),
103
142
  gates: feature.approvals || feature.approvalGates || {},
104
143
  verification: feature.verification ?? null,
105
144
  failStreak: feature.verifications?.feature?.failStreak ?? 0,
@@ -12,10 +12,10 @@ export const PHASE_DIRS = {
12
12
  export const OUTPUT_PHASE_MAP = {
13
13
  plan: 'plan',
14
14
  checks: 'plan',
15
- uiDesignSystem: 'uiux',
15
+ planCritique: 'plan',
16
+ planView: 'plan',
16
17
  uiMockups: 'uiux',
17
- uiComponents: 'uiux',
18
- uiFlows: 'uiux',
18
+ implementReport: 'implement',
19
19
  evidencias: 'implement',
20
20
  verifierReport: 'review',
21
21
  reviewerReport: 'review',
@@ -25,10 +25,10 @@ export const OUTPUT_PHASE_MAP = {
25
25
  export const FILENAME_TO_OUTPUT = {
26
26
  'plan.md': 'plan',
27
27
  'checks.json': 'checks',
28
- 'design-system.md': 'uiDesignSystem',
28
+ 'critica.md': 'planCritique',
29
+ 'plan.html': 'planView',
29
30
  'mockups.md': 'uiMockups',
30
- 'components.md': 'uiComponents',
31
- 'flows.md': 'uiFlows',
31
+ 'report.md': 'implementReport',
32
32
  'verifier.md': 'verifierReport',
33
33
  'reviewer.md': 'reviewerReport',
34
34
  'security.md': 'securityReport',
@@ -37,10 +37,7 @@ export const FILENAME_TO_OUTPUT = {
37
37
  export const PROTECTED_SPEC_FILES = {
38
38
  'plan.md': 'plan',
39
39
  'checks.json': 'plan',
40
- 'design-system.md': 'uiux',
41
40
  'mockups.md': 'uiux',
42
- 'components.md': 'uiux',
43
- 'flows.md': 'uiux',
44
41
  };
45
42
 
46
43
 
@@ -0,0 +1,86 @@
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 STATUSES = ['pendente', 'feito', 'bloqueado'];
8
+
9
+ export function checksPath(featureDir) {
10
+ return join(featureDir, '2-plan', 'checks.json');
11
+ }
12
+
13
+ export function reportPath(featureDir) {
14
+ return join(featureDir, '3-implement', 'report.md');
15
+ }
16
+
17
+ const ROW = /^\|\s*(C\d+)\s*\|\s*([^|]*?)\s*\|\s*(.*?)\s*\|?\s*$/;
18
+
19
+ /** Status per Cn from the report's table; an unknown status reads as `pendente`. */
20
+ export function parseReport(text) {
21
+ const rows = new Map();
22
+ for (const line of String(text ?? '').split(/\r?\n/)) {
23
+ const m = ROW.exec(line);
24
+ if (!m) continue;
25
+ const status = m[2].toLowerCase();
26
+ rows.set(m[1], { status: STATUSES.includes(status) ? status : 'pendente', evidence: m[3] });
27
+ }
28
+ return rows;
29
+ }
30
+
31
+ // Features started before 10.0 have per-slice reports and no report.md: a Cn counts as done when a
32
+ // slice that covers it has a final report, so an in-flight feature keeps moving after the upgrade.
33
+ function legacyRows(checks, readSliceReport) {
34
+ const rows = new Map();
35
+ for (const slice of Array.isArray(checks.slices) ? checks.slices : []) {
36
+ const first = String(readSliceReport(slice.id) ?? '').split(/\r?\n/).find((l) => l.trim() !== '');
37
+ if (first === undefined) continue;
38
+ const status = /^[\s#>*_]*BLOQUEADO\b/.test(first) ? 'bloqueado' : PARTIAL_MARKER.test(first) ? null : 'feito';
39
+ if (!status) continue;
40
+ for (const id of slice.covers ?? []) {
41
+ if (rows.get(id)?.status !== 'bloqueado') rows.set(id, { status, evidence: `${slice.id}.md` });
42
+ }
43
+ }
44
+ return rows;
45
+ }
46
+
47
+ /** @returns {{ total, done, blocked, pending, checks: Array<{id, status, evidence}>, legacy: boolean } | null} */
48
+ export function implementProgressFrom(checksText, reportText, readSliceReport = () => null) {
49
+ let checks;
50
+ try {
51
+ checks = JSON.parse(checksText ?? '');
52
+ } catch {
53
+ return null;
54
+ }
55
+ if (!Array.isArray(checks?.checks)) return null;
56
+ const legacy = reportText == null && Array.isArray(checks.slices);
57
+ const rows = legacy ? legacyRows(checks, readSliceReport) : parseReport(reportText);
58
+ const out = checks.checks
59
+ .filter((c) => c && typeof c.id === 'string')
60
+ .map((c) => ({ id: c.id, status: rows.get(c.id)?.status ?? 'pendente', evidence: rows.get(c.id)?.evidence ?? '' }));
61
+ const count = (status) => out.filter((c) => c.status === status).length;
62
+ return { total: out.length, done: count('feito'), blocked: count('bloqueado'), pending: count('pendente'), checks: out, legacy };
63
+ }
64
+
65
+ function readOrNull(path) {
66
+ try {
67
+ return existsSync(path) ? readFileSync(path, 'utf8') : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ export function implementProgress(featureDir) {
74
+ const sliceDir = join(featureDir, '3-implement', 'reports');
75
+ return implementProgressFrom(
76
+ readOrNull(checksPath(featureDir)),
77
+ readOrNull(reportPath(featureDir)),
78
+ (id) => (existsSync(sliceDir) ? readOrNull(join(sliceDir, `${id}.md`)) : null),
79
+ );
80
+ }
81
+
82
+ /** The report template body for a feature's checks: every Cn starts `pendente`. */
83
+ export function reportSkeleton(featureName, checkIds) {
84
+ const rows = checkIds.map((id) => `| ${id} | pendente | |`).join('\n');
85
+ return `# ${featureName}\n\n| Cn | status | evidência |\n|----|--------|-----------|\n${rows}\n\n## Notas\n\n`;
86
+ }
@@ -131,8 +131,9 @@ export const PHASE_INDEX = { plan: 0, uiux: 1, implement: 2, review: 3 };
131
131
  // not advance the phase.
132
132
  export const PHASE_ARTIFACTS = [
133
133
  ['4-review', 'review', 'verifier.md'],
134
+ ['3-implement', 'implement', 'report.md'],
134
135
  ['3-implement', 'implement', 'reports/'],
135
- ['1-design', 'uiux', 'design-system.md'],
136
+ ['1-design', 'uiux', 'mockups.md'],
136
137
  ['2-plan', 'plan', 'plan.md'],
137
138
  ];
138
139
 
@@ -180,7 +181,7 @@ export function derivePhaseFrom(feature, hasArtifact) {
180
181
 
181
182
  // An approved gate advances the phase before the next artifact exists, or
182
183
  // that artifact could never be written.
183
- const designStarted = hasArtifact(DESIGN_DIR) || hasArtifact(`${DESIGN_DIR}/design-system.md`);
184
+ const designStarted = hasArtifact(DESIGN_DIR) || hasArtifact(`${DESIGN_DIR}/mockups.md`);
184
185
  const implied = gateImpliedPhaseFrom(feature, designStarted);
185
186
  return PHASE_INDEX[implied] > PHASE_INDEX[artifactPhase] ? implied : artifactPhase;
186
187
  }