@polymorphism-tech/morph-spec 4.18.0 → 4.19.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 (44) hide show
  1. package/README.md +2 -2
  2. package/bin/task-manager.js +12 -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/agents/architecture/architecture-expert.md +86 -0
  7. package/framework/agents/architecture/morph-spec-architect.md +66 -0
  8. package/framework/agents/architecture/packages-validator.md +77 -0
  9. package/framework/agents/architecture/security-expert.md +67 -0
  10. package/framework/agents/backend/ms-agent-expert.md +1 -1
  11. package/framework/agents/frontend/design-system-validator.md +88 -0
  12. package/framework/agents/frontend/nextjs-component-validator.md +85 -0
  13. package/framework/agents/frontend/{ui-ux-designer.md → ui-designer.md} +31 -0
  14. package/framework/agents.json +1 -1
  15. package/framework/hooks/claude-code/post-tool-use/dispatch.js +39 -0
  16. package/framework/hooks/claude-code/post-tool-use/handle-tool-failure.js +78 -44
  17. package/framework/hooks/claude-code/post-tool-use/loop-detect.js +103 -0
  18. package/framework/hooks/claude-code/session-start/inject-morph-context.js +21 -2
  19. package/framework/hooks/claude-code/stop/validate-completion.js +48 -1
  20. package/framework/hooks/shared/activity-logger.js +56 -1
  21. package/framework/hooks/shared/build-check.js +79 -0
  22. package/framework/hooks/shared/payload-utils.js +39 -0
  23. package/framework/hooks/shared/session-snapshot.js +105 -0
  24. package/framework/phases.json +1 -1
  25. package/framework/skills/level-0-meta/mcp-registry.json +34 -1
  26. package/framework/skills/level-0-meta/morph-frontend-review/SKILL.md +1 -1
  27. package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +1 -1
  28. package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +11 -1
  29. package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +12 -1
  30. package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +1 -1
  31. package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +1 -1
  32. package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +119 -11
  33. package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
  34. package/framework/standards/ai-agents/production.md +5 -2
  35. package/framework/standards/ai-agents/setup.md +13 -14
  36. package/framework/standards/ai-agents/workflows.md +8 -6
  37. package/framework/standards/backend/database/vector-search-rag.md +1 -1
  38. package/framework/templates/code/dotnet/jobs/agent.cs +0 -1
  39. package/package.json +1 -1
  40. package/src/commands/mcp/mcp-setup.js +21 -0
  41. package/src/commands/project/init.js +11 -0
  42. package/src/lib/contracts/sprint-contract-generator.js +137 -0
  43. package/src/lib/installers/mcp-installer.js +25 -0
  44. package/src/utils/hooks-installer.js +12 -1
@@ -1,44 +1,78 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * PostToolUseFailure Hook: Log Tool Failures
5
- *
6
- * Event: PostToolUseFailure
7
- *
8
- * Runs when any tool fails. Appends a structured JSON entry to
9
- * .morph/logs/tool-failures.log for debugging and pattern analysis.
10
- *
11
- * Fail-open: exits 0 on any error.
12
- */
13
-
14
- import { appendFileSync, mkdirSync } from 'fs';
15
- import { join } from 'path';
16
- import { readStdin } from '../../shared/stdin-reader.js';
17
- import { stateExists } from '../../shared/state-reader.js';
18
- import { logHookActivity } from '../../shared/activity-logger.js';
19
-
20
- try {
21
- // Only log in morph-spec projects
22
- if (!stateExists()) {
23
- process.exit(0);
24
- }
25
-
26
- const input = await readStdin();
27
-
28
- const logDir = join(process.cwd(), '.morph', 'logs');
29
- mkdirSync(logDir, { recursive: true });
30
-
31
- const entry = {
32
- timestamp: new Date().toISOString(),
33
- tool: input?.tool_name ?? 'unknown',
34
- error: input?.error ?? null,
35
- input: input?.tool_input ?? null,
36
- };
37
-
38
- appendFileSync(join(logDir, 'tool-failures.log'), JSON.stringify(entry) + '\n');
39
- logHookActivity('handle-tool-failure', 'PostToolUseFailure', `logged(${entry.tool})`);
40
- } catch {
41
- // Fail-open: logging is non-critical
42
- }
43
-
44
- process.exit(0);
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * PostToolUseFailure Hook: Log Tool Failures + Rollback Awareness
5
+ *
6
+ * Event: PostToolUseFailure
7
+ *
8
+ * Runs when any tool fails. Appends a structured JSON entry to
9
+ * .morph/logs/tool-failures.log for debugging and pattern analysis.
10
+ *
11
+ * If a critical failure occurs and there are unstaged changes,
12
+ * suggests git stash as a recovery option.
13
+ *
14
+ * Fail-open: exits 0 on any error.
15
+ */
16
+
17
+ import { appendFileSync, mkdirSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { execSync } from 'child_process';
20
+ import { readStdin } from '../../shared/stdin-reader.js';
21
+ import { stateExists } from '../../shared/state-reader.js';
22
+ import { injectContext } from '../../shared/hook-response.js';
23
+ import { logHookActivity, logEvent } from '../../shared/activity-logger.js';
24
+
25
+ const CRITICAL_ERROR_PATTERNS = ['ENOENT', 'EPERM', 'EACCES', 'segfault', 'out of memory', 'ENOMEM'];
26
+
27
+ try {
28
+ // Only log in morph-spec projects
29
+ if (!stateExists()) {
30
+ process.exit(0);
31
+ }
32
+
33
+ const input = await readStdin();
34
+
35
+ const logDir = join(process.cwd(), '.morph', 'logs');
36
+ mkdirSync(logDir, { recursive: true });
37
+
38
+ const entry = {
39
+ timestamp: new Date().toISOString(),
40
+ tool: input?.tool_name ?? 'unknown',
41
+ error: input?.error ?? null,
42
+ input: input?.tool_input ?? null,
43
+ };
44
+
45
+ appendFileSync(join(logDir, 'tool-failures.log'), JSON.stringify(entry) + '\n');
46
+ logHookActivity('handle-tool-failure', 'PostToolUseFailure', `logged(${entry.tool})`);
47
+
48
+ // Rollback awareness: check for unstaged changes after critical failures
49
+ const errorStr = JSON.stringify(input?.error || '');
50
+ const isCritical = CRITICAL_ERROR_PATTERNS.some(p => errorStr.includes(p));
51
+
52
+ logEvent('tool_failure', { tool: entry.tool, critical: isCritical });
53
+
54
+ if (isCritical) {
55
+ try {
56
+ const gitStatus = execSync('git status --porcelain', {
57
+ cwd: process.cwd(),
58
+ encoding: 'utf-8',
59
+ timeout: 5000,
60
+ stdio: ['pipe', 'pipe', 'pipe']
61
+ }).trim();
62
+
63
+ if (gitStatus) {
64
+ injectContext(
65
+ `⚠️ [morph-spec] Critical tool failure (${entry.tool}). ` +
66
+ `Unstaged changes detected:\n${gitStatus.split('\n').slice(0, 5).join('\n')}\n\n` +
67
+ `Consider: git stash to save current state before retrying.`
68
+ );
69
+ }
70
+ } catch {
71
+ // git not available or not a git repo — ignore
72
+ }
73
+ }
74
+ } catch {
75
+ // Fail-open: logging is non-critical
76
+ }
77
+
78
+ process.exit(0);
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * PostToolUse Hook: Loop Detection
5
+ *
6
+ * Event: PostToolUse | Matcher: none (all tools)
7
+ *
8
+ * Detects when an agent is stuck in a repetitive loop by tracking
9
+ * the last 15 actions. If the last 8 actions have <= 2 unique
10
+ * signatures, injects a warning with escape instructions.
11
+ *
12
+ * Cooldown: 5 minutes between warnings to prevent meta-loop.
13
+ *
14
+ * Fail-open: exits 0 on any error.
15
+ */
16
+
17
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { readStdin } from '../../shared/stdin-reader.js';
20
+ import { stateExists } from '../../shared/state-reader.js';
21
+ import { injectContext, pass } from '../../shared/hook-response.js';
22
+ import { getCommandSignature } from '../../shared/payload-utils.js';
23
+ import { logEvent } from '../../shared/activity-logger.js';
24
+
25
+ const MAX_BUFFER = 15;
26
+ const LOOP_WINDOW = 8;
27
+ const MAX_UNIQUE_SIGS = 2;
28
+ const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
29
+
30
+ try {
31
+ if (!stateExists()) pass();
32
+
33
+ const payload = await readStdin();
34
+ if (!payload) pass();
35
+
36
+ const cwd = process.cwd();
37
+ const memoryDir = join(cwd, '.morph', 'memory');
38
+ const bufferPath = join(memoryDir, 'action-buffer.json');
39
+ const cooldownPath = join(memoryDir, 'loop-cooldown.json');
40
+
41
+ mkdirSync(memoryDir, { recursive: true });
42
+
43
+ // Read existing buffer
44
+ let buffer = [];
45
+ try {
46
+ if (existsSync(bufferPath)) {
47
+ buffer = JSON.parse(readFileSync(bufferPath, 'utf-8'));
48
+ if (!Array.isArray(buffer)) buffer = [];
49
+ }
50
+ } catch {
51
+ buffer = [];
52
+ }
53
+
54
+ // Append current action
55
+ const sig = getCommandSignature(payload);
56
+ buffer.push({ sig, ts: new Date().toISOString() });
57
+
58
+ // Trim to max size (FIFO)
59
+ if (buffer.length > MAX_BUFFER) {
60
+ buffer = buffer.slice(-MAX_BUFFER);
61
+ }
62
+
63
+ // Save updated buffer
64
+ writeFileSync(bufferPath, JSON.stringify(buffer), 'utf-8');
65
+
66
+ // Check for loop (need at least LOOP_WINDOW actions)
67
+ if (buffer.length < LOOP_WINDOW) pass();
68
+
69
+ const recent = buffer.slice(-LOOP_WINDOW);
70
+ const uniqueSigs = new Set(recent.map(a => a.sig));
71
+
72
+ if (uniqueSigs.size > MAX_UNIQUE_SIGS) pass();
73
+
74
+ // Check cooldown
75
+ try {
76
+ if (existsSync(cooldownPath)) {
77
+ const cooldown = JSON.parse(readFileSync(cooldownPath, 'utf-8'));
78
+ const elapsed = Date.now() - new Date(cooldown.lastDetected).getTime();
79
+ if (elapsed < COOLDOWN_MS) pass();
80
+ }
81
+ } catch {
82
+ // Ignore corrupt cooldown — proceed with detection
83
+ }
84
+
85
+ // Loop detected — set cooldown and warn
86
+ writeFileSync(cooldownPath, JSON.stringify({ lastDetected: new Date().toISOString() }), 'utf-8');
87
+
88
+ logEvent('loop_detected', { signatures: uniqueSigs.size }, process.cwd());
89
+
90
+ const sigList = [...uniqueSigs].join(', ');
91
+ injectContext(
92
+ `⚠️ [morph-spec] LOOP DETECTED — the last ${LOOP_WINDOW} actions repeat only ${uniqueSigs.size} pattern(s): ${sigList}\n\n` +
93
+ `This usually means you're stuck. Stop and try a different approach:\n` +
94
+ ` 1. Re-read the error message carefully — what is it actually saying?\n` +
95
+ ` 2. Try a completely different fix strategy\n` +
96
+ ` 3. If the test keeps failing, check if the test itself is wrong\n` +
97
+ ` 4. If blocked, ask the user for guidance\n\n` +
98
+ `Do NOT continue the same pattern.`
99
+ );
100
+ } catch {
101
+ // Fail-open
102
+ process.exit(0);
103
+ }
@@ -12,9 +12,10 @@
12
12
  */
13
13
 
14
14
  import { loadState, getActiveFeature, getPendingGates, getMissingOutputs, derivePhaseForFeature, getTaskStats } from '../../shared/state-reader.js';
15
+ import { loadSessionSnapshot, formatPreviousSession } from '../../shared/session-snapshot.js';
15
16
  import { stateExists } from '../../shared/state-reader.js';
16
17
  import { injectContext, pass } from '../../shared/hook-response.js';
17
- import { resetActivity, logHookActivity } from '../../shared/activity-logger.js';
18
+ import { resetActivity, logHookActivity, archiveSession, logEvent } from '../../shared/activity-logger.js';
18
19
  import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
19
20
  import { join } from 'path';
20
21
  import { resetStaleTasks } from '../../shared/stale-task-reset.js';
@@ -74,10 +75,14 @@ try {
74
75
 
75
76
  const active = getActiveFeature();
76
77
 
77
- // Reset activity log for new session
78
+ // Archive previous session log before resetting
78
79
  const activeFeatureName = active?.name || '';
79
80
  const activePhase = active ? derivePhaseForFeature(active.name) : '';
81
+ archiveSession();
82
+
83
+ // Reset activity log for new session
80
84
  resetActivity(new Date().toISOString(), activeFeatureName, activePhase);
85
+ logEvent('session_start', { feature: activeFeatureName, phase: activePhase });
81
86
  logHookActivity('inject-morph-context', 'SessionStart', 'ok');
82
87
 
83
88
  // ── Stale task cleanup ──────────────────────────────────────────────────────
@@ -201,6 +206,20 @@ try {
201
206
  // Fail-open — worktree context must never block the session
202
207
  }
203
208
  // ────────────────────────────────────────────────────────────────────────────
209
+
210
+ // ── Previous session context ──────────────────────────────────────────────
211
+ // Inject summary from previous session for "getting up to speed"
212
+ try {
213
+ const snapshot = loadSessionSnapshot(process.cwd());
214
+ const previousSessionText = formatPreviousSession(snapshot);
215
+ if (previousSessionText) {
216
+ lines.push('');
217
+ lines.push(previousSessionText);
218
+ }
219
+ } catch {
220
+ // Fail-open — previous session context must never block
221
+ }
222
+ // ────────────────────────────────────────────────────────────────────────
204
223
  } else {
205
224
  // Show summary of all features
206
225
  const featureNames = Object.keys(state.features);
@@ -20,11 +20,13 @@
20
20
 
21
21
  import { readFileSync, writeFileSync, existsSync } from 'fs';
22
22
  import { join } from 'path';
23
+ import { runBuildCheck } from '../../shared/build-check.js';
24
+ import { saveSessionSnapshot } from '../../shared/session-snapshot.js';
23
25
  import {
24
26
  stateExists, getActiveFeature, getMostRecentFeature, getMissingOutputs, derivePhaseForFeature,
25
27
  } from '../../shared/state-reader.js';
26
28
  import { injectContext, pass } from '../../shared/hook-response.js';
27
- import { logHookActivity } from '../../shared/activity-logger.js';
29
+ import { logHookActivity, logEvent } from '../../shared/activity-logger.js';
28
30
 
29
31
  // Phases where compaction is strongly recommended before starting
30
32
  const COMPACT_TRIGGER_PHASES = new Set(['implement']);
@@ -82,6 +84,50 @@ try {
82
84
  );
83
85
  }
84
86
 
87
+ // ── Build/typecheck verification ──────────────────────────────────────────
88
+ // Stack-aware build check — advisory warning if build fails.
89
+ // Success is silent (no output on pass).
90
+ try {
91
+ const configPath = join(cwd, '.morph', 'config', 'config.json');
92
+ if (existsSync(configPath)) {
93
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
94
+ const stack = config?.project?.stack;
95
+ if (stack && currentPhase === 'implement') {
96
+ const buildResult = runBuildCheck(cwd, stack);
97
+ if (buildResult.status === 'fail') {
98
+ warnings.push('⚠ Build/typecheck failed:');
99
+ warnings.push(buildResult.output);
100
+ warnings.push('Fix build errors before ending session.');
101
+ }
102
+ // pass and skip → silent (success is silent)
103
+ }
104
+ }
105
+ } catch {
106
+ // Fail-open — build check must never block Stop
107
+ }
108
+ // ────────────────────────────────────────────────────────────────────────
109
+
110
+ // ── Session snapshot ──────────────────────────────────────────────────────
111
+ // Save session state for next session's "getting up to speed" routine.
112
+ try {
113
+ if (active) {
114
+ const { name, feature } = active;
115
+ const taskList = feature.taskList || [];
116
+ saveSessionSnapshot({
117
+ feature: name,
118
+ phase: currentPhase,
119
+ tasksCompleted: taskList.filter(t => t.status === 'completed').map(t => t.id),
120
+ tasksRemaining: taskList.filter(t => t.status === 'pending').map(t => t.id),
121
+ tasksInProgress: taskList.filter(t => t.status === 'in_progress').map(t => t.id),
122
+ buildStatus: 'skip',
123
+ lastEditedFiles: [],
124
+ }, cwd);
125
+ }
126
+ } catch {
127
+ // Fail-open — snapshot save must never block Stop
128
+ }
129
+ // ────────────────────────────────────────────────────────────────────────
130
+
85
131
  // ── Incomplete work checks ────────────────────────────────────────────────
86
132
 
87
133
  // Incomplete tasks during implement phase
@@ -152,6 +198,7 @@ try {
152
198
  ].join('\n');
153
199
 
154
200
  logHookActivity('validate-completion', 'Stop', `warnings(${warnings.length})`);
201
+ logEvent('session_end', { feature: name, phase: currentPhase, warnings: warnings.length });
155
202
  injectContext(message);
156
203
  } catch {
157
204
  process.exit(0);
@@ -11,7 +11,7 @@
11
11
  * - Windows-safe paths via path.join
12
12
  */
13
13
 
14
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
14
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
15
15
  import { join } from 'path';
16
16
 
17
17
  const LOGS_DIR = '.morph/logs';
@@ -103,3 +103,58 @@ export function resetActivity(sessionId, feature, phase, projectPath) {
103
103
  // Fail-silent
104
104
  }
105
105
  }
106
+
107
+ /**
108
+ * Log a structured event to the session activity log.
109
+ *
110
+ * @param {string} type - Event type (e.g., 'task_start', 'loop_detected')
111
+ * @param {Object} data - Event data payload
112
+ * @param {string} [projectPath] - Project root (defaults to cwd)
113
+ */
114
+ export function logEvent(type, data, projectPath) {
115
+ try {
116
+ const activityPath = getActivityPath(projectPath);
117
+ ensureLogsDir(projectPath);
118
+
119
+ const now = new Date();
120
+ const ts = now.toTimeString().slice(0, 8); // HH:MM:SS
121
+
122
+ const activity = readRaw(activityPath) || { sessionId: '', feature: '', phase: '', hooks: [], events: [] };
123
+ activity.events = activity.events || [];
124
+ activity.events.push({ type, data, ts });
125
+
126
+ writeFileSync(activityPath, JSON.stringify(activity, null, 2), 'utf-8');
127
+ } catch {
128
+ // Fail-silent
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Archive current activity.json to sessions/ directory for historical logging.
134
+ * Called at the start of a new session.
135
+ *
136
+ * @param {string} [projectPath] - Project root (defaults to cwd)
137
+ */
138
+ export function archiveSession(projectPath) {
139
+ try {
140
+ const activityPath = getActivityPath(projectPath);
141
+ if (!existsSync(activityPath)) return;
142
+
143
+ const data = readRaw(activityPath);
144
+ if (!data) return;
145
+
146
+ // Only archive if there are meaningful events
147
+ if ((!data.hooks || data.hooks.length === 0) && (!data.events || data.events.length === 0)) return;
148
+
149
+ const sessionsDir = join(projectPath || process.cwd(), LOGS_DIR, 'sessions');
150
+ mkdirSync(sessionsDir, { recursive: true });
151
+
152
+ const sessionId = (data.sessionId || new Date().toISOString()).replace(/[:.]/g, '-');
153
+ const archivePath = join(sessionsDir, `${sessionId}.json`);
154
+ writeFileSync(archivePath, JSON.stringify(data, null, 2), 'utf-8');
155
+
156
+ unlinkSync(activityPath);
157
+ } catch {
158
+ // Fail-silent
159
+ }
160
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Build/Typecheck verification utility.
3
+ *
4
+ * Stack-aware: determines the correct build command based on project stack.
5
+ * Used by the Stop hook to verify code health before session ends.
6
+ *
7
+ * Pure functions — no I/O except the actual build execution.
8
+ */
9
+
10
+ import { execSync } from 'child_process';
11
+ import { existsSync } from 'fs';
12
+ import { join } from 'path';
13
+
14
+ /**
15
+ * Determine the build command for a given stack.
16
+ *
17
+ * @param {string} stack - Project stack (e.g., 'dotnet-nextjs', 'nextjs')
18
+ * @param {boolean} [hasTsConfig] - Whether tsconfig.json exists (for TS projects)
19
+ * @returns {string|null} Build command or null if no check available
20
+ */
21
+ export function getBuildCommand(stack, hasTsConfig = false) {
22
+ if (!stack) return null;
23
+ const s = stack.toLowerCase();
24
+
25
+ if (s.includes('dotnet')) {
26
+ return 'dotnet build --no-restore --nologo -v q 2>&1 | tail -20';
27
+ }
28
+
29
+ if ((s.includes('nextjs') || s.includes('node') || s.includes('react')) && hasTsConfig) {
30
+ return 'npx tsc --noEmit 2>&1 | tail -20';
31
+ }
32
+
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Parse build command result into a structured object.
38
+ *
39
+ * @param {number} exitCode - Process exit code (0 = success)
40
+ * @param {string} output - stdout+stderr from build
41
+ * @returns {{ status: 'pass'|'fail', output: string }}
42
+ */
43
+ export function parseBuildResult(exitCode, output) {
44
+ const trimmed = (output || '').trim();
45
+ if (exitCode === 0) {
46
+ return { status: 'pass', output: '' };
47
+ }
48
+ // Truncate to last 20 lines
49
+ const lines = trimmed.split('\n');
50
+ const truncated = lines.slice(-20).join('\n');
51
+ return { status: 'fail', output: truncated };
52
+ }
53
+
54
+ /**
55
+ * Run build check for a project.
56
+ *
57
+ * @param {string} cwd - Project root directory
58
+ * @param {string} stack - Project stack string
59
+ * @param {number} [timeout=30000] - Timeout in ms
60
+ * @returns {{ status: 'pass'|'fail'|'skip', output: string }}
61
+ */
62
+ export function runBuildCheck(cwd, stack, timeout = 30000) {
63
+ const hasTsConfig = existsSync(join(cwd, 'tsconfig.json'));
64
+ const cmd = getBuildCommand(stack, hasTsConfig);
65
+ if (!cmd) return { status: 'skip', output: '' };
66
+
67
+ try {
68
+ const output = execSync(cmd, {
69
+ cwd,
70
+ encoding: 'utf-8',
71
+ timeout,
72
+ stdio: ['pipe', 'pipe', 'pipe']
73
+ });
74
+ return parseBuildResult(0, output);
75
+ } catch (err) {
76
+ const output = (err.stdout || '') + (err.stderr || '');
77
+ return parseBuildResult(err.status || 1, output);
78
+ }
79
+ }
@@ -37,3 +37,42 @@ export function getContentToValidate(payload) {
37
37
  export function isWriteOperation(payload) {
38
38
  return typeof payload?.tool_input?.content === 'string';
39
39
  }
40
+
41
+ /**
42
+ * Get the tool name from a hook payload.
43
+ * @param {Object} payload - Hook payload from stdin
44
+ * @returns {string} Tool name or 'unknown'
45
+ */
46
+ export function getToolName(payload) {
47
+ return payload?.tool_name || 'unknown';
48
+ }
49
+
50
+ /**
51
+ * Build a normalized signature for loop detection.
52
+ * - Edit/Write: "Edit:normalized/file/path"
53
+ * - Bash: "Bash:first two words" (e.g., "Bash:npm test")
54
+ * - Other: just the tool name
55
+ *
56
+ * @param {Object} payload - Hook payload from stdin
57
+ * @returns {string} Signature string
58
+ */
59
+ export function getCommandSignature(payload) {
60
+ const tool = getToolName(payload);
61
+ if (tool === 'unknown') return 'unknown';
62
+
63
+ const input = payload?.tool_input;
64
+ if (!input) return tool;
65
+
66
+ if (tool === 'Edit' || tool === 'Write') {
67
+ const path = (input.file_path || input.path || '').replace(/\\/g, '/');
68
+ return path ? `${tool}:${path}` : tool;
69
+ }
70
+
71
+ if (tool === 'Bash') {
72
+ const cmd = (input.command || '').trim();
73
+ const words = cmd.split(/\s+/).slice(0, 2).join(' ');
74
+ return words ? `Bash:${words}` : 'Bash';
75
+ }
76
+
77
+ return tool;
78
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Session Snapshot — cross-session persistence utility
3
+ *
4
+ * Saves and loads session state for "getting up to speed" on next session.
5
+ * Single file (overwrites) — history preserved via git.
6
+ *
7
+ * Fail-silent: never throws.
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from 'fs';
11
+ import { join } from 'path';
12
+
13
+ const SNAPSHOT_PATH = '.morph/memory/session-snapshot.json';
14
+
15
+ /**
16
+ * Save session snapshot atomically.
17
+ * @param {Object} data - Snapshot data
18
+ * @param {string} [projectPath] - Project root (defaults to cwd)
19
+ */
20
+ export function saveSessionSnapshot(data, projectPath) {
21
+ try {
22
+ const root = projectPath || process.cwd();
23
+ const dir = join(root, '.morph', 'memory');
24
+ mkdirSync(dir, { recursive: true });
25
+
26
+ const snapshot = { ...data, savedAt: new Date().toISOString() };
27
+ const filePath = join(root, SNAPSHOT_PATH);
28
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
29
+
30
+ writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2), 'utf-8');
31
+ try {
32
+ renameSync(tmpPath, filePath);
33
+ } catch {
34
+ writeFileSync(filePath, JSON.stringify(snapshot, null, 2), 'utf-8');
35
+ try { unlinkSync(tmpPath); } catch { /* ignore */ }
36
+ }
37
+ } catch {
38
+ // Fail-silent
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Load session snapshot.
44
+ * @param {string} [projectPath] - Project root (defaults to cwd)
45
+ * @returns {Object|null} Parsed snapshot or null
46
+ */
47
+ export function loadSessionSnapshot(projectPath) {
48
+ try {
49
+ const filePath = join(projectPath || process.cwd(), SNAPSHOT_PATH);
50
+ if (!existsSync(filePath)) return null;
51
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Format snapshot as concise context string for SessionStart injection.
59
+ * Budget: max ~150 tokens (~600 chars).
60
+ *
61
+ * @param {Object|null} snapshot
62
+ * @returns {string|null} Formatted context or null
63
+ */
64
+ export function formatPreviousSession(snapshot) {
65
+ if (!snapshot) return null;
66
+
67
+ const lines = ['=== PREVIOUS SESSION ==='];
68
+
69
+ // Header line
70
+ const date = snapshot.savedAt ? new Date(snapshot.savedAt).toISOString().slice(0, 16).replace('T', ' ') : '?';
71
+ const phase = snapshot.phase || '?';
72
+ const build = snapshot.buildStatus || 'unknown';
73
+ lines.push(`Last: ${date} | Phase: ${phase} | Build: ${build}`);
74
+
75
+ // Tasks (truncated)
76
+ const done = snapshot.tasksCompleted || [];
77
+ if (done.length > 0) {
78
+ const shown = done.slice(-5).join(', ');
79
+ const prefix = done.length > 5 ? `...${done.length} total, last: ` : '';
80
+ lines.push(`Done: ${prefix}${shown}`);
81
+ }
82
+
83
+ const wip = snapshot.tasksInProgress || [];
84
+ if (wip.length > 0) {
85
+ lines.push(`WIP: ${wip.join(', ')}`);
86
+ }
87
+
88
+ const remaining = snapshot.tasksRemaining || [];
89
+ if (remaining.length > 0) {
90
+ const shown = remaining.slice(0, 5).join(', ');
91
+ const suffix = remaining.length > 5 ? ` +${remaining.length - 5} more` : '';
92
+ lines.push(`Remaining: ${shown}${suffix}`);
93
+ }
94
+
95
+ // Files (truncated)
96
+ const files = snapshot.lastEditedFiles || [];
97
+ if (files.length > 0) {
98
+ const shown = files.slice(0, 3).join(', ');
99
+ const suffix = files.length > 3 ? ` +${files.length - 3} more` : '';
100
+ lines.push(`Files: ${shown}${suffix}`);
101
+ }
102
+
103
+ lines.push('===');
104
+ return lines.join('\n');
105
+ }
@@ -35,7 +35,7 @@
35
35
  ],
36
36
  "requiredOutputs": ["uiDesignSystem"],
37
37
  "optionalOutputs": ["uiMockups", "uiComponents", "uiFlows"],
38
- "recommendedMCPs": ["playwright", "context7"],
38
+ "recommendedMCPs": ["playwright", "context7", "stitch"],
39
39
  "pausePoints": [
40
40
  { "id": "uiux", "label": "Approve UI/UX Design" }
41
41
  ],