@polymorphism-tech/morph-spec 4.8.19 → 4.9.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 (137) hide show
  1. package/CLAUDE.md +21 -0
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +15 -56
  4. package/bin/task-manager.js +115 -14
  5. package/bin/validate.js +67 -33
  6. package/claude-plugin.json +1 -1
  7. package/docs/CHEATSHEET.md +201 -203
  8. package/docs/QUICKSTART.md +2 -2
  9. package/framework/CLAUDE.md +21 -0
  10. package/framework/agents.json +698 -176
  11. package/framework/hooks/claude-code/post-tool-use/context-refresh.js +1 -1
  12. package/framework/hooks/claude-code/post-tool-use/dispatch.js +2 -2
  13. package/framework/hooks/claude-code/post-tool-use/skill-reminder.js +155 -0
  14. package/framework/hooks/claude-code/pre-tool-use/protect-spec-files.js +1 -1
  15. package/framework/hooks/claude-code/session-start/inject-morph-context.js +71 -2
  16. package/framework/hooks/claude-code/statusline.py +76 -30
  17. package/framework/hooks/claude-code/user-prompt/set-terminal-title.js +14 -6
  18. package/framework/hooks/shared/activity-logger.js +0 -24
  19. package/framework/hooks/shared/phase-utils.js +3 -0
  20. package/framework/hooks/shared/skill-reminder-helpers.js +79 -0
  21. package/framework/hooks/shared/stale-task-reset.js +57 -0
  22. package/framework/hooks/shared/state-reader.js +2 -2
  23. package/framework/hooks/shared/worktree-helpers.js +53 -0
  24. package/framework/phases.json +40 -8
  25. package/framework/skills/level-0-meta/brainstorming/SKILL.md +1 -1
  26. package/framework/skills/level-0-meta/code-review/SKILL.md +1 -1
  27. package/framework/skills/level-0-meta/code-review-nextjs/SKILL.md +163 -163
  28. package/framework/skills/level-0-meta/frontend-review/SKILL.md +5 -5
  29. package/framework/skills/level-0-meta/morph-checklist/SKILL.md +2 -2
  30. package/framework/skills/level-0-meta/morph-init/SKILL.md +5 -5
  31. package/framework/skills/level-0-meta/morph-replicate/SKILL.md +4 -4
  32. package/framework/skills/level-0-meta/morph-replicate/references/blazor-html-mapping.md +1 -1
  33. package/framework/skills/level-0-meta/post-implementation/SKILL.md +59 -12
  34. package/framework/skills/level-0-meta/simulation-checklist/SKILL.md +1 -1
  35. package/framework/skills/level-0-meta/terminal-title/SKILL.md +1 -1
  36. package/framework/skills/level-0-meta/tool-usage-guide/SKILL.md +1 -1
  37. package/framework/skills/level-0-meta/tool-usage-guide/references/tools-per-phase.md +6 -5
  38. package/framework/skills/level-0-meta/verification-before-completion/SKILL.md +1 -1
  39. package/framework/skills/level-1-workflows/phase-clarify/SKILL.md +215 -189
  40. package/framework/skills/level-1-workflows/phase-codebase-analysis/SKILL.md +251 -251
  41. package/framework/skills/level-1-workflows/phase-design/SKILL.md +382 -365
  42. package/framework/skills/level-1-workflows/phase-implement/SKILL.md +492 -450
  43. package/framework/skills/level-1-workflows/phase-setup/SKILL.md +194 -190
  44. package/framework/skills/level-1-workflows/phase-tasks/SKILL.md +270 -270
  45. package/framework/skills/level-1-workflows/phase-uiux/SKILL.md +285 -285
  46. package/framework/standards/STANDARDS.json +640 -88
  47. package/framework/standards/infrastructure/vercel/vercel-database.md +106 -0
  48. package/framework/templates/REGISTRY.json +1825 -1909
  49. package/framework/templates/context/CONTEXT-FEATURE.md +276 -276
  50. package/framework/templates/docs/onboarding.md +1 -5
  51. package/package.json +2 -6
  52. package/src/commands/agents/dispatch-agents.js +55 -4
  53. package/src/commands/project/doctor.js +16 -47
  54. package/src/commands/project/init.js +1 -1
  55. package/src/commands/project/status.js +2 -2
  56. package/src/commands/project/update.js +381 -365
  57. package/src/commands/project/worktree.js +154 -0
  58. package/src/commands/state/advance-phase.js +120 -30
  59. package/src/commands/state/approve.js +2 -2
  60. package/src/commands/state/index.js +7 -8
  61. package/src/commands/state/phase-runner.js +1 -1
  62. package/src/commands/state/state.js +61 -6
  63. package/src/commands/tasks/task.js +78 -99
  64. package/src/commands/templates/template-render.js +93 -173
  65. package/src/commands/trust/trust.js +26 -21
  66. package/src/core/paths/output-schema.js +15 -0
  67. package/src/core/state/state-manager.js +28 -54
  68. package/src/core/workflows/workflow-detector.js +9 -87
  69. package/src/lib/phase-chain/phase-validator.js +330 -0
  70. package/src/lib/stack/stack-profile.js +88 -0
  71. package/src/lib/tasks/task-classifier.js +16 -0
  72. package/src/lib/tasks/test-runner.js +77 -0
  73. package/src/lib/trust/trust-manager.js +32 -144
  74. package/src/lib/validators/spec-validator.js +58 -4
  75. package/src/lib/validators/validation-runner.js +23 -11
  76. package/src/scripts/setup-infra.js +240 -224
  77. package/src/utils/agents-installer.js +2 -2
  78. package/src/utils/banner.js +1 -1
  79. package/src/utils/claude-settings-manager.js +1 -1
  80. package/src/utils/file-copier.js +1 -0
  81. package/src/utils/hooks-installer.js +258 -8
  82. package/framework/hooks/dev/check-sync-health.js +0 -117
  83. package/framework/hooks/dev/guard-version-numbers.js +0 -57
  84. package/framework/hooks/dev/sync-standards-registry.js +0 -60
  85. package/framework/hooks/dev/sync-template-registry.js +0 -60
  86. package/framework/hooks/dev/validate-skill-format.js +0 -70
  87. package/framework/hooks/dev/validate-standard-format.js +0 -73
  88. package/framework/templates/meta-prompts/hops/hop-retry.md +0 -78
  89. package/framework/templates/meta-prompts/hops/hop-validation.md +0 -97
  90. package/framework/templates/meta-prompts/hops/hop-wrapper.md +0 -36
  91. package/framework/workflows/configs/design-impl.json +0 -49
  92. package/framework/workflows/configs/express.json +0 -45
  93. package/framework/workflows/configs/fast-track.json +0 -42
  94. package/framework/workflows/configs/full-morph.json +0 -79
  95. package/framework/workflows/configs/fusion.json +0 -39
  96. package/framework/workflows/configs/long-running.json +0 -33
  97. package/framework/workflows/configs/spec-only.json +0 -43
  98. package/framework/workflows/configs/ui-refresh.json +0 -49
  99. package/framework/workflows/configs/zero-touch.json +0 -82
  100. package/src/commands/project/monitor.js +0 -295
  101. package/src/commands/project/tutorial.js +0 -115
  102. package/src/commands/state/validate-phase.js +0 -238
  103. package/src/commands/templates/generate-contracts.js +0 -445
  104. package/src/core/orchestrator.js +0 -171
  105. package/src/core/registry/command-registry.js +0 -28
  106. package/src/core/registry/index.js +0 -8
  107. package/src/core/registry/validator-registry.js +0 -204
  108. package/src/core/templates/template-validator.js +0 -296
  109. package/src/generator/config-generator.js +0 -206
  110. package/src/generator/templates/config.json.template +0 -40
  111. package/src/generator/templates/project.md.template +0 -67
  112. package/src/lib/agents/micro-agent-factory.js +0 -161
  113. package/src/lib/analysis/complexity-analyzer.js +0 -441
  114. package/src/lib/analysis/index.js +0 -7
  115. package/src/lib/analytics/analytics-engine.js +0 -345
  116. package/src/lib/checkpoints/checkpoint-hooks.js +0 -298
  117. package/src/lib/checkpoints/index.js +0 -7
  118. package/src/lib/context/context-bundler.js +0 -241
  119. package/src/lib/context/context-optimizer.js +0 -212
  120. package/src/lib/context/context-tracker.js +0 -273
  121. package/src/lib/context/core-four-tracker.js +0 -201
  122. package/src/lib/context/mcp-optimizer.js +0 -200
  123. package/src/lib/execution/fusion-executor.js +0 -304
  124. package/src/lib/execution/parallel-executor.js +0 -270
  125. package/src/lib/hooks/stop-hook-executor.js +0 -286
  126. package/src/lib/hops/hop-composer.js +0 -221
  127. package/src/lib/phase-chain/eligibility-checker.js +0 -243
  128. package/src/lib/threads/thread-coordinator.js +0 -238
  129. package/src/lib/threads/thread-manager.js +0 -317
  130. package/src/lib/tracking/artifact-trail.js +0 -202
  131. package/src/scanner/project-scanner.js +0 -242
  132. package/src/ui/diff-display.js +0 -91
  133. package/src/ui/interactive-wizard.js +0 -96
  134. package/src/ui/user-review.js +0 -211
  135. package/src/ui/wizard-questions.js +0 -188
  136. package/src/utils/color-utils.js +0 -70
  137. package/src/utils/process-handler.js +0 -97
@@ -102,7 +102,7 @@ function isContextInitialized() {
102
102
  const contextPath = join(process.cwd(), '.morph/context/README.md');
103
103
  if (!existsSync(contextPath)) return false;
104
104
  const content = readFileSync(contextPath, 'utf-8');
105
- return !content.includes('Run /morph-init to generate');
105
+ return !content.includes('Run /morph:init to generate');
106
106
  } catch {
107
107
  return false;
108
108
  }
@@ -58,7 +58,7 @@ function dispatch(command) {
58
58
  // Non-blocking
59
59
  }
60
60
 
61
- logHookActivity('dispatch.js', 'PostToolUse', dispatchResult);
61
+ logHookActivity('phase-dispatch', 'PostToolUse', dispatchResult);
62
62
  pass();
63
63
  }
64
64
 
@@ -67,7 +67,7 @@ function dispatch(command) {
67
67
  if (phaseAdvanceMatch) {
68
68
  const [, featureName] = phaseAdvanceMatch;
69
69
  evaluatePhaseChain(featureName);
70
- logHookActivity('dispatch.js', 'PostToolUse', 'phase_chain');
70
+ logHookActivity('phase-dispatch', 'PostToolUse', 'phase_chain');
71
71
  pass();
72
72
  }
73
73
 
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * PostToolUse Hook: Skill Reminder on Task Start
5
+ *
6
+ * Event: PostToolUse | Matcher: Bash
7
+ *
8
+ * Fires after `morph-spec task start <feature> <task>` completes.
9
+ * Reads requiredSkills from phases.json for the current phase and injects
10
+ * mandatory skill invocation instructions as additionalContext.
11
+ *
12
+ * The additionalContext appears directly in Claude's view of the Bash tool
13
+ * result — more prominent than any pre-loaded prompt text.
14
+ *
15
+ * Fail-open: exits 0 on any error.
16
+ */
17
+
18
+ import { readFileSync, existsSync } from 'fs';
19
+ import { join } from 'path';
20
+ import { readStdin } from '../../shared/stdin-reader.js';
21
+ import { stateExists } from '../../shared/state-reader.js';
22
+ import { injectContext, pass } from '../../shared/hook-response.js';
23
+ import { logHookActivity } from '../../shared/activity-logger.js';
24
+
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ // Pure helpers (exported for testing)
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Parse `morph-spec task start <feature> <taskId>` from a bash command string.
31
+ * Returns { featureName, taskId } or null if the command doesn't match.
32
+ *
33
+ * @param {string} command
34
+ * @returns {{ featureName: string, taskId: string } | null}
35
+ */
36
+ export function parseTaskStartCommand(command) {
37
+ if (!command || typeof command !== 'string') return null;
38
+ const match = command.match(/morph-spec\s+task\s+start\s+(\S+)\s+(\S+)/);
39
+ if (!match) return null;
40
+ return { featureName: match[1], taskId: match[2] };
41
+ }
42
+
43
+ /**
44
+ * Build the mandatory skill reminder message for a task start event.
45
+ * Returns null if no relevant skills exist for this phase.
46
+ *
47
+ * @param {string} featureName
48
+ * @param {string} taskId
49
+ * @param {string} phase
50
+ * @param {Object|null} phasesData - Parsed phases.json content
51
+ * @returns {string|null}
52
+ */
53
+ export function buildSkillReminderMessage(featureName, taskId, phase, phasesData) {
54
+ try {
55
+ const allSkills = phasesData?.phases?.[phase]?.requiredSkills;
56
+ if (!allSkills || allSkills.length === 0) return null;
57
+
58
+ const nowSkills = allSkills.filter(s => s.trigger === 'beforeEachTask');
59
+ const laterSkills = allSkills.filter(s => s.trigger === 'beforeTaskDone');
60
+ const bugSkills = allSkills.filter(s => s.trigger === 'onBugOrUnexpected');
61
+
62
+ if (nowSkills.length === 0 && laterSkills.length === 0) return null;
63
+
64
+ const lines = [];
65
+ lines.push(`⚠️ MORPH-SPEC SKILL REMINDER — Task ${taskId} started (phase: ${phase})`);
66
+ lines.push('');
67
+
68
+ if (nowSkills.length > 0) {
69
+ lines.push('INVOKE NOW before writing any code:');
70
+ for (const s of nowSkills) {
71
+ lines.push(` → Skill(${s.skill})`);
72
+ }
73
+ lines.push('');
74
+ }
75
+
76
+ if (laterSkills.length > 0) {
77
+ lines.push('INVOKE BEFORE marking task done:');
78
+ for (const s of laterSkills) {
79
+ lines.push(` → Skill(${s.skill})`);
80
+ }
81
+ lines.push('');
82
+ }
83
+
84
+ if (bugSkills.length > 0) {
85
+ lines.push('IF you encounter a bug or unexpected behavior:');
86
+ for (const s of bugSkills) {
87
+ lines.push(` → Skill(${s.skill})`);
88
+ }
89
+ lines.push('');
90
+ }
91
+
92
+ lines.push('These are MANDATORY. Use the Skill() tool. Do NOT skip.');
93
+ return lines.join('\n');
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+ // Hook entry point
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+
103
+ try {
104
+ if (!stateExists()) pass();
105
+
106
+ const payload = await readStdin();
107
+ if (!payload) pass();
108
+
109
+ const command = payload?.tool_input?.command || '';
110
+ const parsed = parseTaskStartCommand(command);
111
+ if (!parsed) pass();
112
+
113
+ const { featureName, taskId } = parsed;
114
+
115
+ // Load state to get current phase
116
+ const statePath = join(process.cwd(), '.morph', 'state.json');
117
+ if (!existsSync(statePath)) pass();
118
+
119
+ let state;
120
+ try {
121
+ state = JSON.parse(readFileSync(statePath, 'utf8'));
122
+ } catch {
123
+ pass();
124
+ }
125
+
126
+ const feature = state?.features?.[featureName];
127
+ if (!feature) pass();
128
+
129
+ const phase = feature.phase;
130
+ if (!phase) pass();
131
+
132
+ // Load phases.json (project-local first, fall back to package-level)
133
+ const localPhasesPath = join(process.cwd(), 'framework', 'phases.json');
134
+ const pkgPhasesPath = join(
135
+ process.cwd(), 'node_modules', '@polymorphism-tech', 'morph-spec', 'framework', 'phases.json'
136
+ );
137
+ const phasesPath = existsSync(localPhasesPath) ? localPhasesPath : pkgPhasesPath;
138
+ if (!existsSync(phasesPath)) pass();
139
+
140
+ let phasesData;
141
+ try {
142
+ phasesData = JSON.parse(readFileSync(phasesPath, 'utf8'));
143
+ } catch {
144
+ pass();
145
+ }
146
+
147
+ const message = buildSkillReminderMessage(featureName, taskId, phase, phasesData);
148
+ if (!message) pass();
149
+
150
+ logHookActivity('skill-reminder', 'PostToolUse', `injected(${featureName}/${taskId}/${phase})`);
151
+ injectContext(message);
152
+ } catch {
153
+ // Fail-open
154
+ process.exit(0);
155
+ }
@@ -58,7 +58,7 @@ try {
58
58
  `MORPH-SPEC: '${filename}' is locked — the '${requiredGate}' gate has been approved.\n` +
59
59
  `Editing approved specs breaks the spec contract.\n\n` +
60
60
  `If changes are truly needed:\n` +
61
- ` 1. Revoke approval: morph-spec approve ${featureName} ${requiredGate} --revoke\n` +
61
+ ` 1. Revoke approval: morph-spec unapprove ${featureName} ${requiredGate}\n` +
62
62
  ` 2. Make your edits\n` +
63
63
  ` 3. Re-approve: morph-spec approve ${featureName} ${requiredGate}`
64
64
  );
@@ -15,8 +15,11 @@ import { loadState, getActiveFeature, getPendingGates, getMissingOutputs, derive
15
15
  import { stateExists } from '../../shared/state-reader.js';
16
16
  import { injectContext, pass } from '../../shared/hook-response.js';
17
17
  import { resetActivity, logHookActivity } from '../../shared/activity-logger.js';
18
- import { readFileSync, existsSync } from 'fs';
18
+ import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync } from 'fs';
19
19
  import { join } from 'path';
20
+ import { resetStaleTasks } from '../../shared/stale-task-reset.js';
21
+ import { execSync as _execSync } from 'child_process';
22
+ import { parseWorktreeResult, buildWorktreeContextLine } from '../../shared/worktree-helpers.js';
20
23
 
21
24
  const DEFAULT_SPEC_MAX_CHARS = 3000;
22
25
 
@@ -43,6 +46,26 @@ function getProjectConfig() {
43
46
 
44
47
  const SPEC_MAX_CHARS = getSpecMaxChars();
45
48
 
49
+ /**
50
+ * Atomic state save for hook context.
51
+ * Same approach as state-manager.js saveState — write to tmp, then rename.
52
+ * Fail-open: any error is silently ignored.
53
+ * @param {Object} state - Full state object
54
+ * @param {string} cwd - Project root path
55
+ */
56
+ function saveStateSync(state, cwd) {
57
+ try {
58
+ state.metadata = state.metadata || {};
59
+ state.metadata.lastUpdated = new Date().toISOString();
60
+ const statePath = join(cwd, '.morph/state.json');
61
+ const tmpPath = `${statePath}.tmp.hook.${process.pid}`;
62
+ writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf8');
63
+ renameSync(tmpPath, statePath);
64
+ } catch {
65
+ try { unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ }
66
+ }
67
+ }
68
+
46
69
  try {
47
70
  if (!stateExists()) pass();
48
71
 
@@ -57,7 +80,19 @@ try {
57
80
  resetActivity(new Date().toISOString(), activeFeatureName, activePhase);
58
81
  logHookActivity('inject-morph-context', 'SessionStart', 'ok');
59
82
 
83
+ // ── Stale task cleanup ──────────────────────────────────────────────────────
84
+ // Reset any in_progress tasks older than 1 hour — they are orphans from an
85
+ // interrupted session and are no longer being actively implemented.
86
+ const staleResetLog = resetStaleTasks(state);
87
+ if (staleResetLog.length > 0) {
88
+ saveStateSync(state, process.cwd());
89
+ }
90
+ // ────────────────────────────────────────────────────────────────────────────
91
+
60
92
  const lines = ['MORPH-SPEC Status:'];
93
+ if (staleResetLog.length > 0) {
94
+ lines.push(`⚠️ ${staleResetLog.length} task(s) auto-reset in_progress → pending (orphaned from previous session): ${staleResetLog.join(', ')}`);
95
+ }
61
96
 
62
97
  if (active) {
63
98
  const { name, feature } = active;
@@ -129,6 +164,40 @@ try {
129
164
  // Non-blocking: skip spec injection on read error
130
165
  }
131
166
  }
167
+
168
+ // ── Worktree setup ──────────────────────────────────────────────────────────
169
+ // For the active feature, create or detect an existing git worktree.
170
+ // Trigger: uses getActiveFeature() (status-based) rather than checking
171
+ // activeAgents, because status is the canonical signal already used by the
172
+ // rest of this hook. activeAgents is populated later in the spec pipeline,
173
+ // so a status-based gate ensures the worktree is always created before work
174
+ // begins — even for features still in early phases.
175
+ // Fail-open: any error is silently ignored.
176
+ try {
177
+ let worktreeStdout = '';
178
+ try {
179
+ worktreeStdout = _execSync(`npx morph-spec worktree setup ${active.name}`, {
180
+ cwd: process.cwd(),
181
+ stdio: 'pipe',
182
+ timeout: 10000
183
+ }).toString();
184
+ } catch (execErr) {
185
+ // exit code 2 = already exists — stdout still has the JSON result
186
+ worktreeStdout = execErr.stdout?.toString() || '';
187
+ }
188
+ const worktreeResult = parseWorktreeResult(worktreeStdout);
189
+ if (worktreeResult) {
190
+ worktreeResult.feature = active.name; // enrich for context builder
191
+ const contextLine = buildWorktreeContextLine(worktreeResult);
192
+ if (contextLine) {
193
+ lines.push('');
194
+ lines.push(contextLine);
195
+ }
196
+ }
197
+ } catch {
198
+ // Fail-open — worktree setup must never block the session
199
+ }
200
+ // ────────────────────────────────────────────────────────────────────────────
132
201
  } else {
133
202
  // Show summary of all features
134
203
  const featureNames = Object.keys(state.features);
@@ -151,7 +220,7 @@ try {
151
220
  lines.push('');
152
221
  lines.push('── MORPH SYSTEM MAP ─────────────────────────────────────────────');
153
222
  lines.push(`🪝 Hooks: 10 registrados | 📏 Rules: em .claude/rules/ | 🎯 Skills: em .claude/skills/`);
154
- lines.push(` Execute \`morph-spec monitor\` em terminal separado para live view`);
223
+ lines.push(` Use \`morph-spec doctor\` para health check | \`morph-spec status ${name}\` para dashboard`);
155
224
  lines.push('─────────────────────────────────────────────────────────────────');
156
225
  }
157
226
 
@@ -151,7 +151,10 @@ def get_session_feature_names(features_dict, entries):
151
151
 
152
152
 
153
153
  def get_all_active_features(cwd, entries):
154
- """Return in_progress features that are active in the current session."""
154
+ """Return in_progress features that are active in the current session.
155
+
156
+ Caller is responsible for only calling this when inside a secondary worktree.
157
+ """
155
158
  state_path = Path(cwd) / '.morph' / 'state.json'
156
159
  if not state_path.exists():
157
160
  return []
@@ -162,11 +165,6 @@ def get_all_active_features(cwd, entries):
162
165
  # Filter to features belonging to this session (mentioned in transcript)
163
166
  session_names = get_session_feature_names(features, entries)
164
167
 
165
- # Auto-detect: if only one feature is in_progress, show it regardless of transcript
166
- in_progress = [n for n, f in features.items() if f.get('status') == 'in_progress']
167
- if len(in_progress) == 1:
168
- session_names.add(in_progress[0])
169
-
170
168
  if not session_names:
171
169
  return []
172
170
 
@@ -310,8 +308,14 @@ def get_git_info(cwd):
310
308
  return ""
311
309
 
312
310
 
313
- def get_worktree_info(cwd):
314
- """Detect if running in a git worktree (not the main worktree)."""
311
+ def get_worktree_data(cwd):
312
+ """Detect worktree status for cwd.
313
+
314
+ Returns (is_secondary: bool, display_str: str).
315
+ is_secondary is True only when cwd is a non-primary git worktree.
316
+ display_str is the formatted label (non-empty only when is_secondary).
317
+ Called once in main() to avoid duplicate git subprocess.
318
+ """
315
319
  try:
316
320
  out = _run_git(['worktree', 'list', '--porcelain'], cwd)
317
321
  entries, current = [], {}
@@ -329,10 +333,45 @@ def get_worktree_info(cwd):
329
333
  for entry in entries[1:]:
330
334
  if str(Path(entry.get('path', '')).resolve()) == cwd_r:
331
335
  branch = entry.get('branch', '').replace('refs/heads/', '')
332
- return f"{MAGENTA}worktree:{branch}{R}"
336
+ return True, f"{MAGENTA}worktree:{branch}{R}"
333
337
  except Exception:
334
338
  pass
335
- return ""
339
+ return False, ""
340
+
341
+
342
+ def get_recent_tool_calls(entries):
343
+ """Scan the last 50 transcript entries for the most recent Skill and Agent tool calls.
344
+
345
+ Returns (last_skill_name, last_agent_name) — either may be None.
346
+ Looks at assistant messages containing tool_use blocks.
347
+ """
348
+ last_skill = None
349
+ last_agent = None
350
+ recent = entries[-50:] if len(entries) > 50 else entries
351
+ for entry in reversed(recent):
352
+ if entry.get('isSidechain') or entry.get('isApiErrorMessage'):
353
+ continue
354
+ msg = entry.get('message') or {}
355
+ content = msg.get('content') or []
356
+ if not isinstance(content, list):
357
+ continue
358
+ # Within each entry, scan content from last to first to get most-recent call
359
+ for item in reversed(content):
360
+ if not isinstance(item, dict) or item.get('type') != 'tool_use':
361
+ continue
362
+ tool = item.get('name', '')
363
+ inp = item.get('input') or {}
364
+ if tool == 'Skill' and last_skill is None:
365
+ last_skill = inp.get('skill') or ''
366
+ elif tool == 'Agent' and last_agent is None:
367
+ agent_name = inp.get('subagent_type') or ''
368
+ if not agent_name:
369
+ desc = inp.get('description') or inp.get('prompt') or ''
370
+ agent_name = (desc[:22] + '…') if len(desc) > 25 else desc
371
+ last_agent = agent_name
372
+ if last_skill is not None and last_agent is not None:
373
+ break
374
+ return last_skill or None, last_agent or None
336
375
 
337
376
 
338
377
  # ── Transcript / JSONL helpers ────────────────────────────────────────────────
@@ -493,12 +532,18 @@ def main():
493
532
  cwd = data.get('cwd', os.getcwd())
494
533
  transcript_path = data.get('transcript_path')
495
534
 
496
- # Read JSONL transcript once — shared by session clock, block timer,
497
- # token metrics, and session name.
535
+ # Read JSONL transcript once — shared by session clock, token metrics,
536
+ # session name, and skill/agent detection.
498
537
  entries = read_transcript_jsonl(transcript_path) if transcript_path else []
499
538
 
539
+ # ── Worktree detection (single git call) ─────────────────────────────────
540
+ # Feature lines are only shown when inside a secondary git worktree.
541
+ # This prevents stale feature names showing up during unrelated work
542
+ # in the main worktree.
543
+ is_worktree, wt_display = get_worktree_data(cwd)
544
+
500
545
  # ── MORPH feature lines (one line per active feature) ────────────────────
501
- features = get_all_active_features(cwd, entries)
546
+ features = get_all_active_features(cwd, entries) if is_worktree else []
502
547
  for feat in features:
503
548
  # Feature name with visual prefix
504
549
  parts = [f"{CYAN}{BOLD}► {feat['name']}{R}"]
@@ -536,19 +581,14 @@ def main():
536
581
 
537
582
  print(' | '.join(parts))
538
583
 
539
- # ── Activity info line (hooks + last event) ──────────────────────────────
540
- if features: # only show when a feature is active
584
+ # ── Activity info line (hooks; only shown when a feature is active) ───────
585
+ if features:
541
586
  activity = get_activity_info(cwd)
542
- if activity and (activity['hook_count'] > 0 or activity['skill_count'] > 0):
543
- act_parts = []
544
- if activity['hook_count'] > 0:
545
- hook_label = activity['last_hook'] or '?'
546
- age_str = f"({activity['last_hook_age']})" if activity['last_hook_age'] else ''
547
- act_parts.append(f"{BLUE}🪝 {hook_label} {age_str}{R}".strip())
548
- if activity['skill_count'] > 0:
549
- act_parts.append(f"{YELLOW}🎯 {activity['skill_count']} skill(s){R}")
550
- if act_parts:
551
- print(f" {GRAY}└{R} " + f" {GRAY}|{R} ".join(act_parts))
587
+ if activity and activity['hook_count'] > 0:
588
+ hook_label = activity['last_hook'] or '?'
589
+ age_str = f"({activity['last_hook_age']})" if activity['last_hook_age'] else ''
590
+ hook_str = f"{BLUE}🪝 {hook_label} {age_str}{R}".strip()
591
+ print(f" {GRAY}└{R} {hook_str}")
552
592
 
553
593
  # ── Session info line (always shown) ─────────────────────────────────────
554
594
  parts2 = []
@@ -580,6 +620,14 @@ def main():
580
620
  }
581
621
  parts2.append(f"{YELLOW}{_perm_labels.get(perm, perm)}{R}")
582
622
 
623
+ # Last skill and agent invoked (parsed from transcript tool_use blocks)
624
+ if entries:
625
+ last_skill, last_agent = get_recent_tool_calls(entries)
626
+ if last_skill:
627
+ parts2.append(f"{YELLOW}🎯 {last_skill}{R}")
628
+ if last_agent:
629
+ parts2.append(f"{MAGENTA}⚡ {last_agent}{R}")
630
+
583
631
  # Session clock (elapsed time since session start, survives transcript transitions)
584
632
  session_start = get_session_start(cwd, transcript_path, entries)
585
633
  duration = get_session_duration(session_start)
@@ -611,16 +659,14 @@ def main():
611
659
  line += f" ({toks})"
612
660
  parts2.append(line + suffix)
613
661
 
614
-
615
662
  # Git info (branch + diff stats)
616
663
  git = get_git_info(cwd)
617
664
  if git:
618
665
  parts2.append(git)
619
666
 
620
- # Worktree info
621
- wt = get_worktree_info(cwd)
622
- if wt:
623
- parts2.append(wt)
667
+ # Worktree label (already computed above — reuse result)
668
+ if wt_display:
669
+ parts2.append(wt_display)
624
670
 
625
671
  if parts2:
626
672
  print(' | '.join(parts2))
@@ -40,21 +40,29 @@ try {
40
40
  const prefix = process.env.CLAUDE_TITLE_PREFIX ? `${process.env.CLAUDE_TITLE_PREFIX} ` : '';
41
41
  const finalTitle = `${prefix}${title}`;
42
42
 
43
- // Save to ~/.claude/terminal_title (for shell hook integration)
43
+ // Save to session-specific file (MORPH_TERMINAL_TITLE_FILE) or fallback to global
44
+ // MORPH_TERMINAL_TITLE_FILE is set by the shell wrapper (Invoke-Claude / claude())
45
+ // per-session so multiple terminal windows don't interfere with each other.
44
46
  try {
47
+ const titleFilePath = process.env.MORPH_TERMINAL_TITLE_FILE
48
+ || join(homedir(), '.claude', 'terminal_title');
45
49
  const claudeDir = join(homedir(), '.claude');
46
50
  await mkdir(claudeDir, { recursive: true });
47
- await writeFile(join(claudeDir, 'terminal_title'), finalTitle, 'utf-8');
51
+ await writeFile(titleFilePath, finalTitle, 'utf-8');
48
52
  } catch { /* non-critical */ }
49
53
 
50
- // Write ANSI escape to /dev/tty (bypasses stdout capture by Claude Code)
54
+ // Write ANSI escape to terminal device (bypasses stdout capture by Claude Code)
55
+ // Windows uses \\.\CONOUT$ as the equivalent of /dev/tty
56
+ let wrote = false;
51
57
  try {
52
- const tty = openSync('/dev/tty', 'w');
58
+ const devicePath = process.platform === 'win32' ? '\\\\.\\CONOUT$' : '/dev/tty';
59
+ const tty = openSync(devicePath, 'w');
53
60
  writeSync(tty, `\x1b]0;${finalTitle}\x07`);
54
61
  closeSync(tty);
55
- } catch { /* terminal may not support /dev/tty */ }
62
+ wrote = true;
63
+ } catch { /* terminal may not support direct device access */ }
56
64
 
57
- logHookActivity('set-terminal-title', 'UserPromptSubmit', 'ok');
65
+ logHookActivity('set-terminal-title', 'UserPromptSubmit', wrote ? 'ok' : 'failed');
58
66
  } catch { /* fail-open */ }
59
67
 
60
68
  process.exit(0);
@@ -64,30 +64,6 @@ export function logHookActivity(name, event, result, projectPath) {
64
64
  }
65
65
  }
66
66
 
67
- /**
68
- * Log a skill invocation to the session activity log.
69
- *
70
- * @param {string} name - Skill name (e.g. 'brainstorming')
71
- * @param {string} [projectPath]
72
- */
73
- export function logSkillActivity(name, projectPath) {
74
- try {
75
- const activityPath = getActivityPath(projectPath);
76
- ensureLogsDir(projectPath);
77
-
78
- const now = new Date();
79
- const ts = now.toTimeString().slice(0, 8);
80
-
81
- const data = readRaw(activityPath) || { sessionId: '', feature: '', phase: '', hooks: [], skills: [] };
82
-
83
- data.skills = data.skills || [];
84
- data.skills.push({ name, ts });
85
-
86
- writeFileSync(activityPath, JSON.stringify(data, null, 2), 'utf-8');
87
- } catch {
88
- // Fail-silent
89
- }
90
- }
91
67
 
92
68
  /**
93
69
  * Read the current activity log.
@@ -29,6 +29,7 @@ export const OUTPUT_PHASE_MAP = {
29
29
  spec: 'design',
30
30
  clarifications: 'clarify',
31
31
  contracts: 'design',
32
+ contractsTs: 'design',
32
33
  contractsVsa: 'design',
33
34
  tasks: 'tasks',
34
35
  uiDesignSystem: 'uiux',
@@ -46,6 +47,7 @@ export const FILENAME_TO_OUTPUT = {
46
47
  'spec.md': 'spec',
47
48
  'clarifications.md': 'clarifications',
48
49
  'contracts.cs': 'contracts',
50
+ 'contracts.ts': 'contractsTs',
49
51
  'contracts-vsa.cs': 'contractsVsa',
50
52
  'tasks.md': 'tasks',
51
53
  'design-system.md': 'uiDesignSystem',
@@ -61,6 +63,7 @@ export const PROTECTED_SPEC_FILES = {
61
63
  'schema-analysis.md': 'design',
62
64
  'spec.md': 'design',
63
65
  'contracts.cs': 'design',
66
+ 'contracts.ts': 'design',
64
67
  'contracts-vsa.cs': 'design',
65
68
  'tasks.md': 'tasks',
66
69
  'design-system.md': 'uiux',
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Pure helper functions for the skill-reminder PostToolUse hook.
3
+ * Extracted to shared/ so they can be unit-tested without running the hook entry point.
4
+ */
5
+
6
+ /**
7
+ * Parse `morph-spec task start <feature> <taskId>` from a bash command string.
8
+ * Returns { featureName, taskId } or null if the command doesn't match.
9
+ *
10
+ * @param {string} command
11
+ * @returns {{ featureName: string, taskId: string } | null}
12
+ */
13
+ export function parseTaskStartCommand(command) {
14
+ if (!command || typeof command !== 'string') return null;
15
+ const match = command.match(/morph-spec\s+task\s+start\s+(\S+)\s+(\S+)/);
16
+ if (!match) return null;
17
+ return { featureName: match[1], taskId: match[2] };
18
+ }
19
+
20
+ /**
21
+ * Build the mandatory skill reminder message for a task start event.
22
+ * Returns null if no relevant skills exist for this phase at task-start time.
23
+ *
24
+ * Relevant triggers:
25
+ * - beforeEachTask → invoke NOW before writing any code
26
+ * - beforeTaskDone → invoke BEFORE marking task done
27
+ * - onBugOrUnexpected → invoke IF a bug is encountered
28
+ *
29
+ * @param {string} featureName
30
+ * @param {string} taskId
31
+ * @param {string} phase
32
+ * @param {Object|null} phasesData - Parsed phases.json content
33
+ * @returns {string|null}
34
+ */
35
+ export function buildSkillReminderMessage(featureName, taskId, phase, phasesData) {
36
+ try {
37
+ const allSkills = phasesData?.phases?.[phase]?.requiredSkills;
38
+ if (!allSkills || allSkills.length === 0) return null;
39
+
40
+ const nowSkills = allSkills.filter(s => s.trigger === 'beforeEachTask');
41
+ const laterSkills = allSkills.filter(s => s.trigger === 'beforeTaskDone');
42
+ const bugSkills = allSkills.filter(s => s.trigger === 'onBugOrUnexpected');
43
+
44
+ if (nowSkills.length === 0 && laterSkills.length === 0) return null;
45
+
46
+ const lines = [];
47
+ lines.push(`⚠️ MORPH-SPEC SKILL REMINDER — Task ${taskId} started (phase: ${phase})`);
48
+ lines.push('');
49
+
50
+ if (nowSkills.length > 0) {
51
+ lines.push('INVOKE NOW before writing any code:');
52
+ for (const s of nowSkills) {
53
+ lines.push(` → Skill(${s.skill})`);
54
+ }
55
+ lines.push('');
56
+ }
57
+
58
+ if (laterSkills.length > 0) {
59
+ lines.push('INVOKE BEFORE marking task done:');
60
+ for (const s of laterSkills) {
61
+ lines.push(` → Skill(${s.skill})`);
62
+ }
63
+ lines.push('');
64
+ }
65
+
66
+ if (bugSkills.length > 0) {
67
+ lines.push('IF you encounter a bug or unexpected behavior:');
68
+ for (const s of bugSkills) {
69
+ lines.push(` → Skill(${s.skill})`);
70
+ }
71
+ lines.push('');
72
+ }
73
+
74
+ lines.push('These are MANDATORY. Use the Skill() tool. Do NOT skip.');
75
+ return lines.join('\n');
76
+ } catch {
77
+ return null;
78
+ }
79
+ }