@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.
- package/README.md +2 -2
- package/bin/task-manager.js +12 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents/architecture/architecture-expert.md +86 -0
- package/framework/agents/architecture/morph-spec-architect.md +66 -0
- package/framework/agents/architecture/packages-validator.md +77 -0
- package/framework/agents/architecture/security-expert.md +67 -0
- package/framework/agents/backend/ms-agent-expert.md +1 -1
- package/framework/agents/frontend/design-system-validator.md +88 -0
- package/framework/agents/frontend/nextjs-component-validator.md +85 -0
- package/framework/agents/frontend/{ui-ux-designer.md → ui-designer.md} +31 -0
- package/framework/agents.json +1 -1
- package/framework/hooks/claude-code/post-tool-use/dispatch.js +39 -0
- package/framework/hooks/claude-code/post-tool-use/handle-tool-failure.js +78 -44
- package/framework/hooks/claude-code/post-tool-use/loop-detect.js +103 -0
- package/framework/hooks/claude-code/session-start/inject-morph-context.js +21 -2
- package/framework/hooks/claude-code/stop/validate-completion.js +48 -1
- package/framework/hooks/shared/activity-logger.js +56 -1
- package/framework/hooks/shared/build-check.js +79 -0
- package/framework/hooks/shared/payload-utils.js +39 -0
- package/framework/hooks/shared/session-snapshot.js +105 -0
- package/framework/phases.json +1 -1
- package/framework/skills/level-0-meta/mcp-registry.json +34 -1
- package/framework/skills/level-0-meta/morph-frontend-review/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +11 -1
- package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +12 -1
- package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +119 -11
- package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
- package/framework/standards/ai-agents/production.md +5 -2
- package/framework/standards/ai-agents/setup.md +13 -14
- package/framework/standards/ai-agents/workflows.md +8 -6
- package/framework/standards/backend/database/vector-search-rag.md +1 -1
- package/framework/templates/code/dotnet/jobs/agent.cs +0 -1
- package/package.json +1 -1
- package/src/commands/mcp/mcp-setup.js +21 -0
- package/src/commands/project/init.js +11 -0
- package/src/lib/contracts/sprint-contract-generator.js +137 -0
- package/src/lib/installers/mcp-installer.js +25 -0
- 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
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|
package/framework/phases.json
CHANGED
|
@@ -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
|
],
|