@polymorphism-tech/morph-spec 4.8.15 → 4.8.17

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 (37) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +8 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/hooks/README.md +4 -2
  7. package/framework/hooks/claude-code/notification/approval-reminder.js +2 -0
  8. package/framework/hooks/claude-code/post-tool-use/context-refresh.js +109 -0
  9. package/framework/hooks/claude-code/post-tool-use/dispatch.js +5 -0
  10. package/framework/hooks/claude-code/post-tool-use/handle-tool-failure.js +2 -0
  11. package/framework/hooks/claude-code/post-tool-use/validator-feedback.js +6 -1
  12. package/framework/hooks/claude-code/pre-compact/save-morph-context.js +2 -0
  13. package/framework/hooks/claude-code/pre-tool-use/enforce-phase-writes.js +7 -1
  14. package/framework/hooks/claude-code/pre-tool-use/protect-spec-files.js +3 -0
  15. package/framework/hooks/claude-code/session-start/inject-morph-context.js +19 -0
  16. package/framework/hooks/claude-code/statusline.py +61 -0
  17. package/framework/hooks/claude-code/stop/validate-completion.js +2 -0
  18. package/framework/hooks/claude-code/teammate-idle/teammate-idle.js +3 -0
  19. package/framework/hooks/claude-code/user-prompt/enrich-prompt.js +6 -1
  20. package/framework/hooks/claude-code/user-prompt/set-terminal-title.js +2 -0
  21. package/framework/hooks/shared/activity-logger.js +129 -0
  22. package/framework/skills/level-0-meta/morph-init/SKILL.md +9 -12
  23. package/framework/skills/level-1-workflows/phase-clarify/SKILL.md +1 -1
  24. package/framework/skills/level-1-workflows/phase-codebase-analysis/SKILL.md +1 -1
  25. package/framework/skills/level-1-workflows/phase-design/SKILL.md +1 -1
  26. package/framework/skills/level-1-workflows/phase-implement/SKILL.md +1 -1
  27. package/framework/skills/level-1-workflows/phase-setup/SKILL.md +1 -1
  28. package/framework/skills/level-1-workflows/phase-tasks/SKILL.md +1 -1
  29. package/framework/skills/level-1-workflows/phase-uiux/SKILL.md +1 -1
  30. package/package.json +1 -1
  31. package/src/commands/project/index.js +1 -0
  32. package/src/commands/project/monitor.js +295 -0
  33. package/src/lib/monitor/agent-resolver.js +144 -0
  34. package/src/lib/monitor/renderer.js +230 -0
  35. package/src/scripts/setup-infra.js +22 -1
  36. package/src/utils/file-copier.js +1 -0
  37. package/src/utils/hooks-installer.js +11 -5
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Activity Logger — Session-scoped hook event log
3
+ *
4
+ * Writes hook activity to .morph/logs/activity.json for live consumption
5
+ * by `morph-spec monitor`.
6
+ *
7
+ * Design constraints:
8
+ * - Synchronous only (hooks run in process, no async overhead)
9
+ * - Fail-silent (never throws — hooks must not be blocked by logger)
10
+ * - ≤5ms per call (no retries, no network)
11
+ * - Windows-safe paths via path.join
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
15
+ import { join } from 'path';
16
+
17
+ const LOGS_DIR = '.morph/logs';
18
+ const ACTIVITY_FILE = join(LOGS_DIR, 'activity.json');
19
+
20
+ function getActivityPath(projectPath) {
21
+ return join(projectPath || process.cwd(), ACTIVITY_FILE);
22
+ }
23
+
24
+ function ensureLogsDir(projectPath) {
25
+ const dir = join(projectPath || process.cwd(), LOGS_DIR);
26
+ if (!existsSync(dir)) {
27
+ mkdirSync(dir, { recursive: true });
28
+ }
29
+ }
30
+
31
+ function readRaw(activityPath) {
32
+ try {
33
+ if (!existsSync(activityPath)) return null;
34
+ return JSON.parse(readFileSync(activityPath, 'utf-8'));
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Log a hook firing event to the session activity log.
42
+ *
43
+ * @param {string} name - Hook name (e.g. 'dispatch.js', 'enrich-prompt')
44
+ * @param {string} event - Claude Code event (e.g. 'PostToolUse', 'UserPromptSubmit')
45
+ * @param {string} result - Outcome summary (e.g. 'ok', 'blocked', 'checkpoint_saved')
46
+ * @param {string} [projectPath] - Project root (defaults to cwd)
47
+ */
48
+ export function logHookActivity(name, event, result, projectPath) {
49
+ try {
50
+ const activityPath = getActivityPath(projectPath);
51
+ ensureLogsDir(projectPath);
52
+
53
+ const now = new Date();
54
+ const ts = now.toTimeString().slice(0, 8); // HH:MM:SS
55
+
56
+ const data = readRaw(activityPath) || { sessionId: '', feature: '', phase: '', hooks: [], skills: [] };
57
+
58
+ data.hooks = data.hooks || [];
59
+ data.hooks.push({ name, event, result, ts });
60
+
61
+ writeFileSync(activityPath, JSON.stringify(data, null, 2), 'utf-8');
62
+ } catch {
63
+ // Fail-silent — never block hooks
64
+ }
65
+ }
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
+
92
+ /**
93
+ * Read the current activity log.
94
+ *
95
+ * @param {string} [projectPath]
96
+ * @returns {{ sessionId: string, feature: string, phase: string, hooks: Array, skills: Array }|null}
97
+ */
98
+ export function readActivity(projectPath) {
99
+ try {
100
+ return readRaw(getActivityPath(projectPath));
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Reset the activity log for a new session.
108
+ * Called from SessionStart hook to clear stale data.
109
+ *
110
+ * @param {string} sessionId - ISO timestamp of new session
111
+ * @param {string} feature - Active feature name (empty string if none)
112
+ * @param {string} phase - Current phase (empty string if none)
113
+ * @param {string} [projectPath]
114
+ */
115
+ export function resetActivity(sessionId, feature, phase, projectPath) {
116
+ try {
117
+ ensureLogsDir(projectPath);
118
+ const data = {
119
+ sessionId: sessionId || new Date().toISOString(),
120
+ feature: feature || '',
121
+ phase: phase || '',
122
+ hooks: [],
123
+ skills: []
124
+ };
125
+ writeFileSync(getActivityPath(projectPath), JSON.stringify(data, null, 2), 'utf-8');
126
+ } catch {
127
+ // Fail-silent
128
+ }
129
+ }
@@ -51,8 +51,8 @@ Read `~/.claude/plugins/installed_plugins.json` and check for:
51
51
  For each missing plugin, run:
52
52
 
53
53
  ```bash
54
- npx morph-spec install-plugin superpowers
55
- npx morph-spec install-plugin context7
54
+ claude plugin install superpowers@claude-plugins-official
55
+ claude plugin install context7@claude-plugins-official
56
56
  ```
57
57
 
58
58
  **If the command succeeds:** `✓ {plugin} installed. Restart Claude Code after /morph-init completes.`
@@ -60,14 +60,10 @@ npx morph-spec install-plugin context7
60
60
  **If the command fails:** Show this and **STOP** — do not continue:
61
61
 
62
62
  ```
63
- ┌─────────────────────────────────────────────────────────────┐
64
- │ Plugin {plugin} requires manual installation:
65
- │ │
66
- │ 1. Claude Code → Settings (Cmd/Ctrl+,) → Extensions │
67
- │ 2. Browse → search "{plugin}" → Install │
68
- │ 3. Restart Claude Code │
69
- │ 4. Re-run /morph-init │
70
- └─────────────────────────────────────────────────────────────┘
63
+ Plugin {plugin} could not be installed automatically.
64
+ Run manually and restart Claude Code:
65
+ claude plugin install {plugin}
66
+ Then re-run /morph-init.
71
67
  ```
72
68
 
73
69
  Both `superpowers` and `context7` are required. Do not continue if either is missing.
@@ -250,7 +246,7 @@ For each detected integration with an available MCP:
250
246
 
251
247
  Only offer Figma, Docker, Azure if explicitly detected in `.env.example`.
252
248
 
253
- > **Format rule:** Always write MCP entries using `cmd /c` format:
249
+ > **Format rule:** Always write MCP entries with `"command": "cmd"` and `"/c"` as the **first element of `"args"`**:
254
250
  > ```json
255
251
  > "mcp-name": {
256
252
  > "command": "cmd",
@@ -258,7 +254,8 @@ Only offer Figma, Docker, Azure if explicitly detected in `.env.example`.
258
254
  > "env": { "KEY": "VALUE" }
259
255
  > }
260
256
  > ```
261
- > Never use `"command": "npx"` directlyit fails on Windows.
257
+ > **Never** use `"command": "cmd /c"` (space-separated string) that is invalid and will fail.
258
+ > **Never** use `"command": "npx"` directly — it also fails on Windows without `cmd`.
262
259
 
263
260
  ---
264
261
 
@@ -4,7 +4,7 @@ description: MORPH-SPEC Phase 3 (Clarify). Reviews spec.md for ambiguities, gene
4
4
  argument-hint: "[feature-name]"
5
5
  user-invocable: false
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
7
- cliVersion: "4.8.15"
7
+ cliVersion: "4.8.17"
8
8
  ---
9
9
 
10
10
  # MORPH Clarify - FASE 3
@@ -3,7 +3,7 @@ name: phase-codebase-analysis
3
3
  description: MORPH-SPEC Design sub-phase that analyzes existing codebase and database schema, producing schema-analysis.md with real column names, types, relationships, and field mismatches. Use at the start of Design phase before generating contracts.cs to prevent incorrect field names or types.
4
4
  user-invocable: false
5
5
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
6
- cliVersion: "4.8.15"
6
+ cliVersion: "4.8.17"
7
7
  ---
8
8
 
9
9
  # MORPH Codebase Analysis - Sub-fase de DESIGN
@@ -4,7 +4,7 @@ description: MORPH-SPEC Phase 2 (Design). Analyzes codebase/schema, then produce
4
4
  argument-hint: "[feature-name]"
5
5
  user-invocable: false
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
7
- cliVersion: "4.8.15"
7
+ cliVersion: "4.8.17"
8
8
  ---
9
9
 
10
10
  # MORPH Design - FASE 2
@@ -6,7 +6,7 @@ disable-model-invocation: true
6
6
  context: fork
7
7
  agent: general-purpose
8
8
  user-invocable: false
9
- cliVersion: "4.8.15"
9
+ cliVersion: "4.8.17"
10
10
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
11
11
  ---
12
12
 
@@ -4,7 +4,7 @@ description: MORPH-SPEC Phase 1 (Setup). Reads project context, detects tech sta
4
4
  argument-hint: "[feature-name]"
5
5
  user-invocable: false
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
7
- cliVersion: "4.8.15"
7
+ cliVersion: "4.8.17"
8
8
  ---
9
9
 
10
10
  # MORPH Setup - FASE 1
@@ -5,7 +5,7 @@ argument-hint: "[feature-name]"
5
5
  disable-model-invocation: true
6
6
  user-invocable: false
7
7
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
8
- cliVersion: "4.8.15"
8
+ cliVersion: "4.8.17"
9
9
  ---
10
10
 
11
11
  # MORPH Tasks - FASE 4
@@ -4,7 +4,7 @@ description: MORPH-SPEC Phase 1.5 (UI/UX). Creates design-system.md, mockups.md,
4
4
  argument-hint: "[feature-name]"
5
5
  user-invocable: false
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
7
- cliVersion: "4.8.15"
7
+ cliVersion: "4.8.17"
8
8
  ---
9
9
 
10
10
  # MORPH UI/UX Design - FASE 1.5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "4.8.15",
3
+ "version": "4.8.17",
4
4
  "description": "MORPH-SPEC: AI-First development framework with validation pipeline and multi-stack support",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -5,3 +5,4 @@ export { initCommand } from './init.js';
5
5
  export { doctorCommand } from './doctor.js';
6
6
  export { updateCommand } from './update.js';
7
7
  export { tutorialCommand } from './tutorial.js';
8
+ export { monitorCommand } from './monitor.js';
@@ -0,0 +1,295 @@
1
+ /**
2
+ * morph-spec monitor — Live TUI Dashboard
3
+ *
4
+ * Opens a live-updating terminal UI showing:
5
+ * - Active agents by tier
6
+ * - Hook events (chronological feed)
7
+ * - Skills invoked this session
8
+ * - Rules in effect
9
+ * - Overview summary
10
+ *
11
+ * Reads .morph/logs/activity.json (written by hooks via activity-logger.js).
12
+ * Uses fs.watch for live updates (no polling, no chokidar).
13
+ *
14
+ * Usage:
15
+ * morph-spec monitor [feature]
16
+ * morph-spec monitor --compact
17
+ * morph-spec monitor --mode hooks
18
+ */
19
+
20
+ import { watch, existsSync, readFileSync, readdirSync, statSync } from 'fs';
21
+ import { join, basename } from 'path';
22
+ import chalk from 'chalk';
23
+
24
+ import {
25
+ renderBox,
26
+ renderFeed,
27
+ renderAgents,
28
+ renderSkills,
29
+ renderRules,
30
+ renderOverview,
31
+ renderStatusBar,
32
+ renderHeader,
33
+ } from '../../lib/monitor/renderer.js';
34
+
35
+ import { getActiveAgents } from '../../lib/monitor/agent-resolver.js';
36
+ import { readActivity } from '../../../framework/hooks/shared/activity-logger.js';
37
+ import { loadState, getActiveFeature, derivePhaseForFeature } from '../../../framework/hooks/shared/state-reader.js';
38
+
39
+ const MODES = ['overview', 'hooks', 'agents', 'skills', 'rules'];
40
+ const ACTIVITY_FILE = '.morph/logs/activity.json';
41
+ const RULES_DIR_PROJECT = '.claude/rules';
42
+ const RULES_DIR_FRAMEWORK = '.morph/framework/rules';
43
+
44
+ // ─────────────────────────────────────────────────────────
45
+ // Rules loading
46
+ // ─────────────────────────────────────────────────────────
47
+
48
+ function loadRules(cwd) {
49
+ const rules = [];
50
+ const dirs = [
51
+ join(cwd, RULES_DIR_PROJECT),
52
+ join(cwd, RULES_DIR_FRAMEWORK),
53
+ join(cwd, 'framework/rules'),
54
+ ];
55
+
56
+ for (const dir of dirs) {
57
+ if (!existsSync(dir)) continue;
58
+ try {
59
+ const files = readdirSync(dir).filter(f => f.endsWith('.md'));
60
+ for (const file of files) {
61
+ // Parse front-matter paths glob if present
62
+ let glob = '*';
63
+ try {
64
+ const content = readFileSync(join(dir, file), 'utf-8');
65
+ const match = content.match(/paths:\s*\n((?:\s*-\s*.+\n?)+)/);
66
+ if (match) {
67
+ const paths = match[1].match(/- (.+)/g);
68
+ if (paths) glob = paths.map(p => p.replace('- ', '').trim()).join(', ');
69
+ }
70
+ } catch { /* skip */ }
71
+ rules.push({ name: basename(file, '.md'), glob });
72
+ }
73
+ } catch { /* skip */ }
74
+ }
75
+ return rules;
76
+ }
77
+
78
+ // ─────────────────────────────────────────────────────────
79
+ // Available skills loading
80
+ // ─────────────────────────────────────────────────────────
81
+
82
+ function loadAvailableSkills(cwd) {
83
+ const skillDirs = [
84
+ join(cwd, '.claude/skills'),
85
+ join(cwd, 'framework/skills'),
86
+ ];
87
+ const names = [];
88
+ for (const dir of skillDirs) {
89
+ if (!existsSync(dir)) continue;
90
+ try {
91
+ const entries = readdirSync(dir, { withFileTypes: true });
92
+ for (const entry of entries) {
93
+ if (entry.isDirectory()) {
94
+ // Level-N-meta/skill-name or direct skill dirs
95
+ const sub = readdirSync(join(dir, entry.name), { withFileTypes: true });
96
+ for (const s of sub) {
97
+ if (s.isDirectory()) names.push(s.name);
98
+ }
99
+ }
100
+ }
101
+ } catch { /* skip */ }
102
+ }
103
+ return [...new Set(names)];
104
+ }
105
+
106
+ // ─────────────────────────────────────────────────────────
107
+ // Screen rendering
108
+ // ─────────────────────────────────────────────────────────
109
+
110
+ function render(state) {
111
+ const { feature, phase, tasks, agentsByTier, activity, rules, availableSkills, mode, compact } = state;
112
+
113
+ const hooks = activity?.hooks || [];
114
+ const skills = activity?.skills || [];
115
+
116
+ // Clear screen
117
+ process.stdout.write('\x1Bc');
118
+
119
+ // Header
120
+ process.stdout.write(renderHeader(feature, phase, tasks) + '\n');
121
+
122
+ if (compact) {
123
+ // Compact: just show overview
124
+ const overviewData = { feature, phase, agentsByTier, hooks, skills, rules };
125
+ process.stdout.write(renderOverview(overviewData) + '\n');
126
+ } else {
127
+ switch (mode) {
128
+ case 'hooks':
129
+ process.stdout.write(renderFeed(hooks) + '\n');
130
+ break;
131
+
132
+ case 'agents':
133
+ process.stdout.write(renderAgents(agentsByTier, phase) + '\n');
134
+ break;
135
+
136
+ case 'skills':
137
+ process.stdout.write(renderSkills(skills, availableSkills) + '\n');
138
+ break;
139
+
140
+ case 'rules':
141
+ process.stdout.write(renderRules(rules) + '\n');
142
+ break;
143
+
144
+ case 'overview':
145
+ default: {
146
+ const overviewData = { feature, phase, agentsByTier, hooks, skills, rules };
147
+ process.stdout.write(renderOverview(overviewData) + '\n');
148
+ break;
149
+ }
150
+ }
151
+ }
152
+
153
+ // Status bar
154
+ process.stdout.write(renderStatusBar(mode, MODES) + '\n');
155
+ }
156
+
157
+ // ─────────────────────────────────────────────────────────
158
+ // State loading
159
+ // ─────────────────────────────────────────────────────────
160
+
161
+ function loadMonitorState(cwd, featureArg) {
162
+ let feature = featureArg || '';
163
+ let phase = '';
164
+ let tasks = null;
165
+
166
+ try {
167
+ const morphState = loadState(cwd);
168
+ if (!feature && morphState) {
169
+ const active = getActiveFeature(cwd);
170
+ if (active) {
171
+ feature = active.name;
172
+ tasks = active.feature.tasks || null;
173
+ }
174
+ }
175
+ if (feature) {
176
+ phase = derivePhaseForFeature(feature, cwd);
177
+ }
178
+ } catch { /* fail-open */ }
179
+
180
+ const agentsByTier = getActiveAgents(cwd, phase);
181
+ const activity = readActivity(cwd);
182
+ const rules = loadRules(cwd);
183
+ const availableSkills = loadAvailableSkills(cwd);
184
+
185
+ return { feature, phase, tasks, agentsByTier, activity, rules, availableSkills };
186
+ }
187
+
188
+ // ─────────────────────────────────────────────────────────
189
+ // Main command
190
+ // ─────────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * @param {string|undefined} featureArg
194
+ * @param {{ compact: boolean, mode: string }} opts
195
+ */
196
+ export function monitorCommand(featureArg, opts = {}) {
197
+ const cwd = process.cwd();
198
+ const compact = !!opts.compact;
199
+ const startMode = MODES.includes(opts.mode) ? opts.mode : 'overview';
200
+
201
+ let modeIndex = MODES.indexOf(startMode);
202
+ let currentMode = startMode;
203
+
204
+ // Initial render
205
+ let monitorState = loadMonitorState(cwd, featureArg);
206
+ render({ ...monitorState, mode: currentMode, compact });
207
+
208
+ // Watch activity.json for changes
209
+ const activityPath = join(cwd, ACTIVITY_FILE);
210
+ let watcher = null;
211
+
212
+ function startWatcher() {
213
+ if (!existsSync(activityPath)) {
214
+ // Retry watch setup when file is created
215
+ setTimeout(startWatcher, 2000);
216
+ return;
217
+ }
218
+
219
+ try {
220
+ watcher = watch(activityPath, { persistent: true }, () => {
221
+ try {
222
+ monitorState = loadMonitorState(cwd, featureArg);
223
+ render({ ...monitorState, mode: currentMode, compact });
224
+ } catch { /* fail-open */ }
225
+ });
226
+ } catch {
227
+ // fs.watch not supported — fall back to polling
228
+ setInterval(() => {
229
+ try {
230
+ monitorState = loadMonitorState(cwd, featureArg);
231
+ render({ ...monitorState, mode: currentMode, compact });
232
+ } catch { /* fail-open */ }
233
+ }, 2000);
234
+ }
235
+ }
236
+
237
+ startWatcher();
238
+
239
+ // Keyboard input: raw mode
240
+ if (process.stdin.isTTY) {
241
+ process.stdin.setRawMode(true);
242
+ process.stdin.resume();
243
+ process.stdin.setEncoding('utf8');
244
+
245
+ process.stdin.on('data', (key) => {
246
+ // q or Ctrl-C → quit
247
+ if (key === 'q' || key === '\u0003') {
248
+ if (watcher) watcher.close();
249
+ process.stdin.setRawMode(false);
250
+ process.stdout.write('\n' + chalk.gray('morph-spec monitor closed.\n'));
251
+ process.exit(0);
252
+ }
253
+
254
+ // shift+tab → cycle modes (forward)
255
+ // \x1b[Z is the standard escape for shift+tab
256
+ if (key === '\x1b[Z' || key === '\t') {
257
+ modeIndex = (modeIndex + 1) % MODES.length;
258
+ currentMode = MODES[modeIndex];
259
+ render({ ...monitorState, mode: currentMode, compact });
260
+ }
261
+
262
+ // Arrow keys: left/right also cycle
263
+ if (key === '\x1b[C') { // right arrow
264
+ modeIndex = (modeIndex + 1) % MODES.length;
265
+ currentMode = MODES[modeIndex];
266
+ render({ ...monitorState, mode: currentMode, compact });
267
+ }
268
+ if (key === '\x1b[D') { // left arrow
269
+ modeIndex = (modeIndex - 1 + MODES.length) % MODES.length;
270
+ currentMode = MODES[modeIndex];
271
+ render({ ...monitorState, mode: currentMode, compact });
272
+ }
273
+
274
+ // r → force refresh
275
+ if (key === 'r') {
276
+ monitorState = loadMonitorState(cwd, featureArg);
277
+ render({ ...monitorState, mode: currentMode, compact });
278
+ }
279
+ });
280
+ } else {
281
+ // Non-TTY: just render once and exit (useful for scripting/piping)
282
+ process.stdout.write('\n' + chalk.gray('(non-interactive mode — run in a real terminal for live updates)\n'));
283
+ }
284
+
285
+ // Handle process signals
286
+ process.on('SIGINT', () => {
287
+ if (watcher) watcher.close();
288
+ process.stdout.write('\n' + chalk.gray('morph-spec monitor closed.\n'));
289
+ process.exit(0);
290
+ });
291
+ process.on('SIGTERM', () => {
292
+ if (watcher) watcher.close();
293
+ process.exit(0);
294
+ });
295
+ }