@polymorphism-tech/morph-spec 4.8.18 → 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.
- package/CLAUDE.md +98 -0
- package/README.md +2 -2
- package/bin/morph-spec.js +15 -56
- package/bin/task-manager.js +115 -14
- package/bin/validate.js +67 -33
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +201 -203
- package/docs/QUICKSTART.md +2 -2
- package/framework/CLAUDE.md +21 -0
- package/framework/agents.json +758 -164
- package/framework/hooks/claude-code/post-tool-use/context-refresh.js +1 -1
- package/framework/hooks/claude-code/post-tool-use/dispatch.js +2 -2
- package/framework/hooks/claude-code/post-tool-use/skill-reminder.js +155 -0
- package/framework/hooks/claude-code/pre-tool-use/protect-spec-files.js +1 -1
- package/framework/hooks/claude-code/session-start/inject-morph-context.js +71 -2
- package/framework/hooks/claude-code/statusline.py +76 -30
- package/framework/hooks/claude-code/user-prompt/set-terminal-title.js +14 -6
- package/framework/hooks/shared/activity-logger.js +0 -24
- package/framework/hooks/shared/phase-utils.js +3 -0
- package/framework/hooks/shared/skill-reminder-helpers.js +79 -0
- package/framework/hooks/shared/stale-task-reset.js +57 -0
- package/framework/hooks/shared/state-reader.js +2 -2
- package/framework/hooks/shared/worktree-helpers.js +53 -0
- package/framework/phases.json +40 -8
- package/framework/skills/level-0-meta/brainstorming/SKILL.md +1 -1
- package/framework/skills/level-0-meta/code-review/SKILL.md +1 -1
- package/framework/skills/level-0-meta/code-review-nextjs/SKILL.md +163 -163
- package/framework/skills/level-0-meta/frontend-review/SKILL.md +5 -5
- package/framework/skills/level-0-meta/morph-checklist/SKILL.md +2 -2
- package/framework/skills/level-0-meta/morph-init/SKILL.md +5 -5
- package/framework/skills/level-0-meta/morph-replicate/SKILL.md +4 -4
- package/framework/skills/level-0-meta/morph-replicate/references/blazor-html-mapping.md +1 -1
- package/framework/skills/level-0-meta/post-implementation/SKILL.md +59 -12
- package/framework/skills/level-0-meta/simulation-checklist/SKILL.md +1 -1
- package/framework/skills/level-0-meta/terminal-title/SKILL.md +1 -1
- package/framework/skills/level-0-meta/tool-usage-guide/SKILL.md +1 -1
- package/framework/skills/level-0-meta/tool-usage-guide/references/tools-per-phase.md +6 -5
- package/framework/skills/level-0-meta/verification-before-completion/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/phase-clarify/SKILL.md +215 -189
- package/framework/skills/level-1-workflows/phase-codebase-analysis/SKILL.md +251 -251
- package/framework/skills/level-1-workflows/phase-design/SKILL.md +382 -365
- package/framework/skills/level-1-workflows/phase-implement/SKILL.md +492 -450
- package/framework/skills/level-1-workflows/phase-setup/SKILL.md +194 -190
- package/framework/skills/level-1-workflows/phase-tasks/SKILL.md +270 -270
- package/framework/skills/level-1-workflows/phase-uiux/SKILL.md +285 -285
- package/framework/standards/STANDARDS.json +640 -88
- package/framework/standards/infrastructure/vercel/vercel-database.md +106 -0
- package/framework/templates/REGISTRY.json +1825 -1909
- package/framework/templates/context/CONTEXT-FEATURE.md +276 -276
- package/framework/templates/docs/onboarding.md +1 -5
- package/framework/workflows/configs/nodejs-cli.json +40 -0
- package/package.json +2 -6
- package/src/commands/agents/dispatch-agents.js +55 -4
- package/src/commands/project/doctor.js +16 -47
- package/src/commands/project/init.js +1 -1
- package/src/commands/project/status.js +2 -2
- package/src/commands/project/update.js +381 -365
- package/src/commands/project/worktree.js +154 -0
- package/src/commands/state/advance-phase.js +120 -30
- package/src/commands/state/approve.js +2 -2
- package/src/commands/state/index.js +7 -8
- package/src/commands/state/phase-runner.js +1 -1
- package/src/commands/state/state.js +61 -6
- package/src/commands/tasks/task.js +78 -99
- package/src/commands/templates/template-render.js +93 -173
- package/src/commands/trust/trust.js +26 -21
- package/src/core/paths/output-schema.js +15 -0
- package/src/core/state/state-manager.js +28 -54
- package/src/core/workflows/workflow-detector.js +9 -87
- package/src/lib/phase-chain/phase-validator.js +330 -0
- package/src/lib/stack/stack-profile.js +88 -0
- package/src/lib/tasks/task-classifier.js +16 -0
- package/src/lib/tasks/test-runner.js +77 -0
- package/src/lib/trust/trust-manager.js +32 -144
- package/src/lib/validators/spec-validator.js +58 -4
- package/src/lib/validators/validation-runner.js +23 -11
- package/src/scripts/setup-infra.js +240 -224
- package/src/utils/agents-installer.js +2 -2
- package/src/utils/banner.js +1 -1
- package/src/utils/claude-settings-manager.js +1 -1
- package/src/utils/file-copier.js +1 -0
- package/src/utils/hooks-installer.js +258 -8
- package/framework/hooks/dev/check-sync-health.js +0 -117
- package/framework/hooks/dev/guard-version-numbers.js +0 -57
- package/framework/hooks/dev/sync-standards-registry.js +0 -60
- package/framework/hooks/dev/sync-template-registry.js +0 -60
- package/framework/hooks/dev/validate-skill-format.js +0 -70
- package/framework/hooks/dev/validate-standard-format.js +0 -73
- package/framework/templates/meta-prompts/hops/hop-retry.md +0 -78
- package/framework/templates/meta-prompts/hops/hop-validation.md +0 -97
- package/framework/templates/meta-prompts/hops/hop-wrapper.md +0 -36
- package/framework/workflows/configs/design-impl.json +0 -49
- package/framework/workflows/configs/express.json +0 -45
- package/framework/workflows/configs/fast-track.json +0 -42
- package/framework/workflows/configs/full-morph.json +0 -79
- package/framework/workflows/configs/fusion.json +0 -39
- package/framework/workflows/configs/long-running.json +0 -33
- package/framework/workflows/configs/spec-only.json +0 -43
- package/framework/workflows/configs/ui-refresh.json +0 -49
- package/framework/workflows/configs/zero-touch.json +0 -82
- package/src/commands/project/monitor.js +0 -295
- package/src/commands/project/tutorial.js +0 -115
- package/src/commands/state/validate-phase.js +0 -238
- package/src/commands/templates/generate-contracts.js +0 -445
- package/src/core/orchestrator.js +0 -171
- package/src/core/registry/command-registry.js +0 -28
- package/src/core/registry/index.js +0 -8
- package/src/core/registry/validator-registry.js +0 -204
- package/src/core/templates/template-validator.js +0 -296
- package/src/generator/config-generator.js +0 -206
- package/src/generator/templates/config.json.template +0 -40
- package/src/generator/templates/project.md.template +0 -67
- package/src/lib/agents/micro-agent-factory.js +0 -161
- package/src/lib/analysis/complexity-analyzer.js +0 -441
- package/src/lib/analysis/index.js +0 -7
- package/src/lib/analytics/analytics-engine.js +0 -345
- package/src/lib/checkpoints/checkpoint-hooks.js +0 -298
- package/src/lib/checkpoints/index.js +0 -7
- package/src/lib/context/context-bundler.js +0 -241
- package/src/lib/context/context-optimizer.js +0 -212
- package/src/lib/context/context-tracker.js +0 -273
- package/src/lib/context/core-four-tracker.js +0 -201
- package/src/lib/context/mcp-optimizer.js +0 -200
- package/src/lib/execution/fusion-executor.js +0 -304
- package/src/lib/execution/parallel-executor.js +0 -270
- package/src/lib/hooks/stop-hook-executor.js +0 -286
- package/src/lib/hops/hop-composer.js +0 -221
- package/src/lib/phase-chain/eligibility-checker.js +0 -243
- package/src/lib/threads/thread-coordinator.js +0 -238
- package/src/lib/threads/thread-manager.js +0 -317
- package/src/lib/tracking/artifact-trail.js +0 -202
- package/src/scanner/project-scanner.js +0 -242
- package/src/ui/diff-display.js +0 -91
- package/src/ui/interactive-wizard.js +0 -96
- package/src/ui/user-review.js +0 -211
- package/src/ui/wizard-questions.js +0 -188
- package/src/utils/color-utils.js +0 -70
- 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
|
|
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
|
|
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
|
|
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
|
|
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(`
|
|
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
|
|
314
|
-
"""Detect
|
|
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,
|
|
497
|
-
#
|
|
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
|
|
540
|
-
if features:
|
|
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
|
|
543
|
-
|
|
544
|
-
if activity['
|
|
545
|
-
|
|
546
|
-
|
|
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
|
|
621
|
-
|
|
622
|
-
|
|
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
|
|
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(
|
|
51
|
+
await writeFile(titleFilePath, finalTitle, 'utf-8');
|
|
48
52
|
} catch { /* non-critical */ }
|
|
49
53
|
|
|
50
|
-
// Write ANSI escape to
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|